feat(editor): copy selected SQL as rich text with highlighting
This commit is contained in:
17 files changed
+339
-4
No files matched your search
@@ -15,7 +15,7 @@ let lastHandledCompressRequestId = 0;
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
|
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
|
||||||
import { AlignLeft, Camera, CaseLower, CaseSensitive, CaseUpper, ClipboardPaste, Code2, Download, Eye, FileCode, MessageSquareText, Minimize2, Pencil, PencilRuler, Play, Copy, List, Scissors, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
|
import { AlignLeft, Camera, CaseLower, CaseSensitive, CaseUpper, ClipboardPaste, Code2, Download, Eye, FileCode, Highlighter, MessageSquareText, Minimize2, Pencil, PencilRuler, Play, Copy, List, Scissors, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
|
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
|
||||||
import { Transaction, StateEffect } from "@codemirror/state";
|
import { Transaction, StateEffect } from "@codemirror/state";
|
||||||
@@ -132,6 +132,7 @@ import {
|
|||||||
} from "@/lib/editor/queryEditorTableDrop";
|
} from "@/lib/editor/queryEditorTableDrop";
|
||||||
import { isPointOverElementRoot } from "@/lib/editor/tableReferenceDragFeedback";
|
import { isPointOverElementRoot } from "@/lib/editor/tableReferenceDragFeedback";
|
||||||
import type { SqlHighlighter } from "@/lib/sql/sqlHighlighter";
|
import type { SqlHighlighter } from "@/lib/sql/sqlHighlighter";
|
||||||
|
import { copySqlAsRichText } from "@/lib/sql/sqlRichText";
|
||||||
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticColors, editorThemeAppearanceFor, loadEditorTheme, editorFontTheme, shellLineCommentTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
|
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticColors, editorThemeAppearanceFor, loadEditorTheme, editorFontTheme, shellLineCommentTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
|
||||||
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
|
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
|
||||||
import { createQueryEditorSqlShortcutDomHandler, isCharacterProducingShortcut } from "@/lib/editor/queryEditorSqlShortcut";
|
import { createQueryEditorSqlShortcutDomHandler, isCharacterProducingShortcut } from "@/lib/editor/queryEditorSqlShortcut";
|
||||||
@@ -1614,6 +1615,18 @@ async function copySelectedSqlFromContextMenu() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 富文本复制:写入 text/html + text/plain,粘贴到邮件/Word/IM 时保留语法高亮。
|
||||||
|
async function copySelectedSqlAsRichTextFromContextMenu() {
|
||||||
|
if (!canCopySelectedSql.value) return;
|
||||||
|
try {
|
||||||
|
await copySqlAsRichText(selectedSql.value);
|
||||||
|
toast(t("grid.copied"));
|
||||||
|
focusEditor();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function cutSelectedSqlFromContextMenu() {
|
async function cutSelectedSqlFromContextMenu() {
|
||||||
if (!canCopySelectedSql.value) return;
|
if (!canCopySelectedSql.value) return;
|
||||||
const currentView = view.value;
|
const currentView = view.value;
|
||||||
@@ -2044,6 +2057,12 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
icon: Copy,
|
icon: Copy,
|
||||||
shortcut: "Mod+C",
|
shortcut: "Mod+C",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("editor.contextMenu.copySelectionAsRichText"),
|
||||||
|
action: copySelectedSqlAsRichTextFromContextMenu,
|
||||||
|
disabled: !canCopySelectedSql.value,
|
||||||
|
icon: Highlighter,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t("editor.contextMenu.screenshotSelection"),
|
label: t("editor.contextMenu.screenshotSelection"),
|
||||||
action: () => {
|
action: () => {
|
||||||
|
|||||||
@@ -1444,6 +1444,7 @@ export default withEnglishFallback({
|
|||||||
export: "İxrac et",
|
export: "İxrac et",
|
||||||
exportQueryResultTo: "Cari sorğunun nəticəsini {format} formatına ixrac et",
|
exportQueryResultTo: "Cari sorğunun nəticəsini {format} formatına ixrac et",
|
||||||
copySelection: "Seçimi kopyala",
|
copySelection: "Seçimi kopyala",
|
||||||
|
copySelectionAsRichText: "Zəngin mətn kimi kopyala",
|
||||||
screenshotSelection: "Seçilmiş kodun ekran görüntüsünü çək",
|
screenshotSelection: "Seçilmiş kodun ekran görüntüsünü çək",
|
||||||
commentSelection: "Seçimi şərhə çevir",
|
commentSelection: "Seçimi şərhə çevir",
|
||||||
blockCommentSelection: "Seçimi blok şərhinə çevir",
|
blockCommentSelection: "Seçimi blok şərhinə çevir",
|
||||||
|
|||||||
@@ -1561,6 +1561,7 @@ export default {
|
|||||||
export: "Export",
|
export: "Export",
|
||||||
exportQueryResultTo: "Export current query result to {format}",
|
exportQueryResultTo: "Export current query result to {format}",
|
||||||
copySelection: "Copy selection",
|
copySelection: "Copy selection",
|
||||||
|
copySelectionAsRichText: "Copy as Rich Text",
|
||||||
screenshotSelection: "Screenshot Selected Code",
|
screenshotSelection: "Screenshot Selected Code",
|
||||||
commentSelection: "Comment selection",
|
commentSelection: "Comment selection",
|
||||||
blockCommentSelection: "Block comment selection",
|
blockCommentSelection: "Block comment selection",
|
||||||
|
|||||||
@@ -1526,6 +1526,7 @@ export default withEnglishFallback({
|
|||||||
export: "Exportar",
|
export: "Exportar",
|
||||||
exportQueryResultTo: "Exportar el conjunto de resultados de la consulta actual a {format}",
|
exportQueryResultTo: "Exportar el conjunto de resultados de la consulta actual a {format}",
|
||||||
copySelection: "Copiar seleccion",
|
copySelection: "Copiar seleccion",
|
||||||
|
copySelectionAsRichText: "Copiar como texto enriquecido",
|
||||||
screenshotSelection: "Capturar código seleccionado",
|
screenshotSelection: "Capturar código seleccionado",
|
||||||
commentSelection: "Comentar selección",
|
commentSelection: "Comentar selección",
|
||||||
blockCommentSelection: "Comentario de bloque en la selección",
|
blockCommentSelection: "Comentario de bloque en la selección",
|
||||||
|
|||||||
@@ -1524,6 +1524,7 @@ export default withEnglishFallback({
|
|||||||
export: "Esporta",
|
export: "Esporta",
|
||||||
exportQueryResultTo: "Esporta il set di risultati della query corrente in {format}",
|
exportQueryResultTo: "Esporta il set di risultati della query corrente in {format}",
|
||||||
copySelection: "Copia selezione",
|
copySelection: "Copia selezione",
|
||||||
|
copySelectionAsRichText: "Copia come testo formattato",
|
||||||
screenshotSelection: "Cattura codice selezionato",
|
screenshotSelection: "Cattura codice selezionato",
|
||||||
commentSelection: "Commenta selezione",
|
commentSelection: "Commenta selezione",
|
||||||
blockCommentSelection: "Commenta selezione a blocco",
|
blockCommentSelection: "Commenta selezione a blocco",
|
||||||
|
|||||||
@@ -1544,6 +1544,7 @@ export default withEnglishFallback({
|
|||||||
export: "エクスポート",
|
export: "エクスポート",
|
||||||
exportQueryResultTo: "現在のクエリ結果セットを {format} にエクスポート",
|
exportQueryResultTo: "現在のクエリ結果セットを {format} にエクスポート",
|
||||||
copySelection: "選択範囲をコピー",
|
copySelection: "選択範囲をコピー",
|
||||||
|
copySelectionAsRichText: "リッチテキストとしてコピー",
|
||||||
screenshotSelection: "選択したコードをスクリーンショット",
|
screenshotSelection: "選択したコードをスクリーンショット",
|
||||||
commentSelection: "選択範囲をコメント化",
|
commentSelection: "選択範囲をコメント化",
|
||||||
blockCommentSelection: "選択範囲をブロックコメント化",
|
blockCommentSelection: "選択範囲をブロックコメント化",
|
||||||
|
|||||||
@@ -1444,6 +1444,7 @@ export default withEnglishFallback({
|
|||||||
export: "내보내기",
|
export: "내보내기",
|
||||||
exportQueryResultTo: "현재 쿼리 결과를 {format}(으)로 내보내기",
|
exportQueryResultTo: "현재 쿼리 결과를 {format}(으)로 내보내기",
|
||||||
copySelection: "선택 영역 복사",
|
copySelection: "선택 영역 복사",
|
||||||
|
copySelectionAsRichText: "서식 있는 텍스트로 복사",
|
||||||
screenshotSelection: "선택한 코드 스크린샷",
|
screenshotSelection: "선택한 코드 스크린샷",
|
||||||
commentSelection: "선택 항목 주석 처리",
|
commentSelection: "선택 항목 주석 처리",
|
||||||
blockCommentSelection: "선택 항목 블록 주석 처리",
|
blockCommentSelection: "선택 항목 블록 주석 처리",
|
||||||
|
|||||||
@@ -1525,6 +1525,7 @@ export default withEnglishFallback({
|
|||||||
export: "Exportar",
|
export: "Exportar",
|
||||||
exportQueryResultTo: "Exportar conjunto de resultados da consulta atual para {format}",
|
exportQueryResultTo: "Exportar conjunto de resultados da consulta atual para {format}",
|
||||||
copySelection: "Copiar seleção",
|
copySelection: "Copiar seleção",
|
||||||
|
copySelectionAsRichText: "Copiar como texto formatado",
|
||||||
screenshotSelection: "Capturar código selecionado",
|
screenshotSelection: "Capturar código selecionado",
|
||||||
commentSelection: "Comentar seleção",
|
commentSelection: "Comentar seleção",
|
||||||
blockCommentSelection: "Comentário de bloco na seleção",
|
blockCommentSelection: "Comentário de bloco na seleção",
|
||||||
|
|||||||
@@ -1446,6 +1446,7 @@ export default withEnglishFallback({
|
|||||||
export: "Dışa Aktar",
|
export: "Dışa Aktar",
|
||||||
exportQueryResultTo: "Geçerli sorgu sonucunu {format} olarak dışa aktar",
|
exportQueryResultTo: "Geçerli sorgu sonucunu {format} olarak dışa aktar",
|
||||||
copySelection: "Seçimi kopyala",
|
copySelection: "Seçimi kopyala",
|
||||||
|
copySelectionAsRichText: "Zengin metin olarak kopyala",
|
||||||
screenshotSelection: "Seçili Kodun Ekran Görüntüsü",
|
screenshotSelection: "Seçili Kodun Ekran Görüntüsü",
|
||||||
commentSelection: "Seçimi yorum satırı yap",
|
commentSelection: "Seçimi yorum satırı yap",
|
||||||
blockCommentSelection: "Seçimi blok yorum yap",
|
blockCommentSelection: "Seçimi blok yorum yap",
|
||||||
|
|||||||
@@ -1476,6 +1476,7 @@ export default withEnglishFallback({
|
|||||||
export: "导出",
|
export: "导出",
|
||||||
exportQueryResultTo: "导出当前查询结果集到 {format}",
|
exportQueryResultTo: "导出当前查询结果集到 {format}",
|
||||||
copySelection: "复制选中内容",
|
copySelection: "复制选中内容",
|
||||||
|
copySelectionAsRichText: "复制为富文本",
|
||||||
screenshotSelection: "截图选中代码",
|
screenshotSelection: "截图选中代码",
|
||||||
commentSelection: "注释选中内容",
|
commentSelection: "注释选中内容",
|
||||||
blockCommentSelection: "块注释选中内容",
|
blockCommentSelection: "块注释选中内容",
|
||||||
|
|||||||
@@ -1524,6 +1524,7 @@ export default withEnglishFallback({
|
|||||||
export: "匯出",
|
export: "匯出",
|
||||||
exportQueryResultTo: "匯出目前查詢結果集到 {format}",
|
exportQueryResultTo: "匯出目前查詢結果集到 {format}",
|
||||||
copySelection: "複製選取內容",
|
copySelection: "複製選取內容",
|
||||||
|
copySelectionAsRichText: "複製為豐富文字",
|
||||||
screenshotSelection: "截圖選中程式碼",
|
screenshotSelection: "截圖選中程式碼",
|
||||||
commentSelection: "註解選取內容",
|
commentSelection: "註解選取內容",
|
||||||
blockCommentSelection: "區塊註解選取內容",
|
blockCommentSelection: "區塊註解選取內容",
|
||||||
|
|||||||
+70
-1
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -87,3 +87,72 @@ describe("clipboardLineEndings", () => {
|
|||||||
expect(clipboardLineEndings("a\nb", "linux")).toBe("a\nb");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { getPlatform, type Platform } from "@/lib/backend/platform";
|
|||||||
interface ClipboardApi {
|
interface ClipboardApi {
|
||||||
readText?: () => Promise<string> | string;
|
readText?: () => Promise<string> | string;
|
||||||
writeText?: (text: string) => Promise<void> | void;
|
writeText?: (text: string) => Promise<void> | void;
|
||||||
|
write?: (items: readonly unknown[]) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClipboardNavigator {
|
interface ClipboardNavigator {
|
||||||
@@ -38,9 +39,17 @@ interface ClipboardDocument {
|
|||||||
execCommand?(command: string): boolean;
|
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 {
|
export interface ClipboardEnvironment {
|
||||||
navigator?: ClipboardNavigator;
|
navigator?: ClipboardNavigator;
|
||||||
document?: ClipboardDocument;
|
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);
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/lib/common/clipboard", () => ({
|
||||||
|
copyRichTextToClipboard: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { copyRichTextToClipboard } from "@/lib/common/clipboard";
|
||||||
|
import { copySqlAsRichText, renderSqlRichTextHtml } from "@/lib/sql/sqlRichText";
|
||||||
|
|
||||||
|
describe("renderSqlRichTextHtml", () => {
|
||||||
|
it("emits token colors as inline styles inside a single pre", async () => {
|
||||||
|
const html = await renderSqlRichTextHtml("SELECT id FROM users;");
|
||||||
|
|
||||||
|
expect(html.startsWith('<pre style="')).toBe(true);
|
||||||
|
expect(html.endsWith("</pre>")).toBe(true);
|
||||||
|
expect(html).toContain('<span style="color:');
|
||||||
|
expect(html).toContain("SELECT");
|
||||||
|
expect(html).toContain("users");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no class attributes or style blocks that mail clients would strip", async () => {
|
||||||
|
const html = await renderSqlRichTextHtml("SELECT id FROM users WHERE name = 'ada';");
|
||||||
|
|
||||||
|
expect(html).not.toContain("class=");
|
||||||
|
expect(html).not.toContain("<style");
|
||||||
|
expect(html).not.toContain("<code");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares a monospace font and light colors on the pre itself", async () => {
|
||||||
|
const html = await renderSqlRichTextHtml("SELECT 1;");
|
||||||
|
const preStyle = html.slice(0, html.indexOf(">"));
|
||||||
|
|
||||||
|
expect(preStyle).toContain("font-family:");
|
||||||
|
expect(preStyle).toContain("monospace");
|
||||||
|
expect(preStyle).toContain("white-space:pre");
|
||||||
|
expect(preStyle).toContain("background-color:#ffffff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes html-sensitive characters from the source", async () => {
|
||||||
|
const html = await renderSqlRichTextHtml("SELECT '<b>&</b>';");
|
||||||
|
|
||||||
|
expect(html).not.toContain("<b>");
|
||||||
|
expect(html).toContain("<b>");
|
||||||
|
expect(html).toContain("&");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one newline per source line break so indentation survives the paste", async () => {
|
||||||
|
const html = await renderSqlRichTextHtml("SELECT a,\n b\nFROM t;");
|
||||||
|
|
||||||
|
expect(html.match(/\n/g)).toHaveLength(2);
|
||||||
|
expect(html).toContain(" b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to uncolored markup for sources too large to tokenize", async () => {
|
||||||
|
const html = await renderSqlRichTextHtml("SELECT 1;\n".repeat(25_000));
|
||||||
|
|
||||||
|
expect(html.startsWith('<pre style="')).toBe(true);
|
||||||
|
expect(html).not.toContain('<span style="color:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("copySqlAsRichText", () => {
|
||||||
|
it("writes the highlighted html alongside the untouched plain text", async () => {
|
||||||
|
const sql = "SELECT id FROM users;";
|
||||||
|
|
||||||
|
await copySqlAsRichText(sql);
|
||||||
|
|
||||||
|
expect(copyRichTextToClipboard).toHaveBeenCalledTimes(1);
|
||||||
|
const [html, text] = vi.mocked(copyRichTextToClipboard).mock.calls[0]!;
|
||||||
|
expect(text).toBe(sql);
|
||||||
|
expect(html).toContain('<span style="color:');
|
||||||
|
expect(html).toContain("SELECT");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ const SHIKI_THEMES = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type ShikiHighlighter = Awaited<ReturnType<typeof import("shiki/core").createHighlighterCore>>;
|
export type ShikiHighlighter = Awaited<ReturnType<typeof import("shiki/core").createHighlighterCore>>;
|
||||||
type ShikiCodeHighlighter = Pick<ShikiHighlighter, "codeToHtml">;
|
type ShikiCodeHighlighter = Pick<ShikiHighlighter, "codeToHtml">;
|
||||||
|
|
||||||
let highlighterPromise: Promise<ShikiHighlighter> | undefined;
|
let highlighterPromise: Promise<ShikiHighlighter> | undefined;
|
||||||
@@ -68,7 +68,12 @@ export function escapeHtml(content: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getShikiSqlHighlighter(): Promise<ShikiHighlighter> {
|
/**
|
||||||
|
* Lazily-initialized shared highlighter. Exported so other SQL renderers
|
||||||
|
* (e.g. rich-text copy) reuse one Shiki instance instead of loading the SQL
|
||||||
|
* grammar and themes a second time.
|
||||||
|
*/
|
||||||
|
export function getShikiSqlHighlighter(): Promise<ShikiHighlighter> {
|
||||||
highlighterPromise ??= loadShikiSqlHighlighter();
|
highlighterPromise ??= loadShikiSqlHighlighter();
|
||||||
return highlighterPromise;
|
return highlighterPromise;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Rich-text (HTML) rendering for SQL, used when copying code out of the editor
|
||||||
|
* into rich-text targets such as email clients, Word, or chat apps.
|
||||||
|
*
|
||||||
|
* Email clients strip `<style>` blocks and `class` attributes, so every token
|
||||||
|
* color is emitted as an inline `style` attribute and the whole snippet lives
|
||||||
|
* in a single `<pre>` that carries its own inline layout. Nothing here depends
|
||||||
|
* on a stylesheet surviving the paste.
|
||||||
|
*
|
||||||
|
* The light theme is forced regardless of the app appearance: some clients
|
||||||
|
* (Outlook's Word-based renderer in particular) drop `background-color`, and
|
||||||
|
* dark text on a white mail body stays readable where light-on-dark would not.
|
||||||
|
*/
|
||||||
|
import { copyRichTextToClipboard } from "@/lib/common/clipboard";
|
||||||
|
import { escapeHtml, getShikiSqlHighlighter, type ShikiHighlighter } from "@/lib/sql/sqlHighlighter";
|
||||||
|
|
||||||
|
const SQL_LANG = "sql";
|
||||||
|
const RICH_TEXT_THEME = "github-light";
|
||||||
|
|
||||||
|
const FOREGROUND = "#24292e";
|
||||||
|
const BACKGROUND = "#ffffff";
|
||||||
|
const BORDER = "#d0d7de";
|
||||||
|
const FONT_STACK = "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace";
|
||||||
|
|
||||||
|
/** Shiki `FontStyle` bitmask values. */
|
||||||
|
const FONT_STYLE_ITALIC = 1;
|
||||||
|
const FONT_STYLE_BOLD = 2;
|
||||||
|
const FONT_STYLE_UNDERLINE = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Above this size we skip tokenizing and emit uncolored markup. Copying a
|
||||||
|
* multi-hundred-kilobyte query should still work, just without highlighting.
|
||||||
|
*/
|
||||||
|
const MAX_HIGHLIGHTED_LENGTH = 200_000;
|
||||||
|
|
||||||
|
interface RichTextToken {
|
||||||
|
content: string;
|
||||||
|
color?: string;
|
||||||
|
fontStyle?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShikiTokenHighlighter = Pick<ShikiHighlighter, "codeToTokensBase">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render SQL as a self-contained, paste-friendly HTML fragment.
|
||||||
|
*
|
||||||
|
* Never throws: if Shiki cannot load or cannot parse the input, the result is
|
||||||
|
* the escaped source in the same `<pre>` shell, so the copy degrades to
|
||||||
|
* uncolored monospace instead of failing.
|
||||||
|
*/
|
||||||
|
export async function renderSqlRichTextHtml(code: string): Promise<string> {
|
||||||
|
if (code.length > MAX_HIGHLIGHTED_LENGTH) {
|
||||||
|
return wrapInPre(escapeHtml(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const highlighter: ShikiTokenHighlighter = await getShikiSqlHighlighter();
|
||||||
|
const lines = highlighter.codeToTokensBase(code, { lang: SQL_LANG, theme: RICH_TEXT_THEME });
|
||||||
|
const body = lines.map((line) => line.map(tokenToHtml).join("")).join("\n");
|
||||||
|
return wrapInPre(body);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[DBX][sqlRichText] Failed to highlight SQL, falling back to plain markup:", error);
|
||||||
|
return wrapInPre(escapeHtml(code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy SQL to the clipboard as both HTML and plain text.
|
||||||
|
*
|
||||||
|
* Rich-text targets get the highlighted snippet; plain-text targets (a terminal,
|
||||||
|
* another SQL editor) get the original `code` unchanged.
|
||||||
|
*/
|
||||||
|
export async function copySqlAsRichText(code: string): Promise<void> {
|
||||||
|
const html = await renderSqlRichTextHtml(code);
|
||||||
|
await copyRichTextToClipboard(html, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenToHtml(token: RichTextToken): string {
|
||||||
|
const style = tokenStyle(token);
|
||||||
|
const escaped = escapeHtml(token.content);
|
||||||
|
return style ? `<span style="${style}">${escaped}</span>` : escaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenStyle(token: RichTextToken): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (token.color) parts.push(`color:${token.color}`);
|
||||||
|
|
||||||
|
const fontStyle = token.fontStyle ?? 0;
|
||||||
|
if (fontStyle & FONT_STYLE_ITALIC) parts.push("font-style:italic");
|
||||||
|
if (fontStyle & FONT_STYLE_BOLD) parts.push("font-weight:bold");
|
||||||
|
if (fontStyle & FONT_STYLE_UNDERLINE) parts.push("text-decoration:underline");
|
||||||
|
|
||||||
|
return parts.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapInPre(innerHtml: string): string {
|
||||||
|
const style = [`font-family:${FONT_STACK}`, "font-size:13px", "line-height:1.55", `color:${FOREGROUND}`, `background-color:${BACKGROUND}`, `border:1px solid ${BORDER}`, "border-radius:6px", "padding:12px 14px", "margin:0", "white-space:pre", "overflow-x:auto"].join(";");
|
||||||
|
|
||||||
|
return `<pre style="${style}">${innerHtml}</pre>`;
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"clipboard-manager:allow-read-text",
|
"clipboard-manager:allow-read-text",
|
||||||
"clipboard-manager:allow-write-text",
|
"clipboard-manager:allow-write-text",
|
||||||
"clipboard-manager:allow-write-image",
|
"clipboard-manager:allow-write-image",
|
||||||
|
"clipboard-manager:allow-write-html",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"dialog:allow-save",
|
"dialog:allow-save",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
|
|||||||
Reference in new issue
Block a user