feat(editor): add code snapshot export
This commit is contained in:
1 parent
a40dc02825
commit
30f5c34729
18 files changed
+774
-7
No files matched your search
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Camera, Check, ClipboardCopy, Download, Moon, Sun } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { renderCodeSnapshotHtml, snapshotElementToPng, copyPngDataUrlToClipboard, savePngDataUrlToFile, type CodeSnapshotSource } from "@/lib/codeSnapshot/codeSnapshot";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
source: CodeSnapshotSource | null;
|
||||
}>();
|
||||
|
||||
const STORAGE_KEY = "dbx:code-snapshot-settings";
|
||||
|
||||
interface CodeSnapshotUiSettings {
|
||||
appearance: "light" | "dark";
|
||||
showTrafficLights: boolean;
|
||||
showLineNumbers: boolean;
|
||||
}
|
||||
|
||||
function loadUiSettings(): CodeSnapshotUiSettings {
|
||||
const fallback: CodeSnapshotUiSettings = {
|
||||
appearance: isDark.value ? "dark" : "light",
|
||||
showTrafficLights: true,
|
||||
showLineNumbers: true,
|
||||
};
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return { ...fallback, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const initialUiSettings = loadUiSettings();
|
||||
const appearance = ref<"light" | "dark">(initialUiSettings.appearance);
|
||||
const showTrafficLights = ref(initialUiSettings.showTrafficLights);
|
||||
const showLineNumbers = ref(initialUiSettings.showLineNumbers);
|
||||
const title = ref("");
|
||||
|
||||
function persistUiSettings() {
|
||||
try {
|
||||
const settings: CodeSnapshotUiSettings = {
|
||||
appearance: appearance.value,
|
||||
showTrafficLights: showTrafficLights.value,
|
||||
showLineNumbers: showLineNumbers.value,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
watch([appearance, showTrafficLights, showLineNumbers], persistUiSettings);
|
||||
|
||||
const snapshotHtml = ref("");
|
||||
const previewWrapRef = ref<HTMLDivElement>();
|
||||
const exporting = ref(false);
|
||||
const copied = ref(false);
|
||||
let copyResetTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function snapshotRoot(): HTMLElement | null {
|
||||
return previewWrapRef.value?.querySelector<HTMLElement>(".dbx-code-snapshot") ?? null;
|
||||
}
|
||||
|
||||
async function renderSnapshot() {
|
||||
if (!open.value || !props.source) return;
|
||||
try {
|
||||
snapshotHtml.value = await renderCodeSnapshotHtml(
|
||||
{
|
||||
code: props.source.code,
|
||||
lang: props.source.lang,
|
||||
title: title.value.trim() || props.source.title,
|
||||
},
|
||||
{
|
||||
appearance: appearance.value,
|
||||
showTrafficLights: showTrafficLights.value,
|
||||
showLineNumbers: showLineNumbers.value,
|
||||
},
|
||||
);
|
||||
} catch (e: any) {
|
||||
snapshotHtml.value = "";
|
||||
toast(t("codeSnapshot.failed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the title for each new snapshot source instead of leaking the
|
||||
// previous snapshot's title across opens.
|
||||
watch(
|
||||
[open, () => props.source],
|
||||
([isOpen, source]) => {
|
||||
if (isOpen && source) title.value = source.title ?? "";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Appearance / toggle options re-render immediately; the title input is
|
||||
// debounced so typing large code blocks stays responsive.
|
||||
watch([open, () => props.source, appearance, showTrafficLights, showLineNumbers], renderSnapshot, {
|
||||
immediate: true,
|
||||
});
|
||||
watch(title, () => {
|
||||
if (titleDebounceTimer) clearTimeout(titleDebounceTimer);
|
||||
titleDebounceTimer = setTimeout(renderSnapshot, 300);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (copyResetTimer) clearTimeout(copyResetTimer);
|
||||
if (titleDebounceTimer) clearTimeout(titleDebounceTimer);
|
||||
});
|
||||
|
||||
function sanitizeFileName(value: string): string {
|
||||
return value.replace(/[\\/:*?"<>|]/g, "-").trim() || "code-snapshot";
|
||||
}
|
||||
|
||||
async function exportSnapshot(kind: "clipboard" | "file") {
|
||||
const root = snapshotRoot();
|
||||
if (!root || exporting.value) return;
|
||||
exporting.value = true;
|
||||
try {
|
||||
const dataUrl = await snapshotElementToPng(root);
|
||||
if (kind === "clipboard") {
|
||||
await copyPngDataUrlToClipboard(dataUrl);
|
||||
copied.value = true;
|
||||
toast(t("codeSnapshot.copied"));
|
||||
if (copyResetTimer) clearTimeout(copyResetTimer);
|
||||
copyResetTimer = setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
} else {
|
||||
const base = sanitizeFileName(title.value.trim() || (props.source?.title ?? "code-snapshot"));
|
||||
const saved = await savePngDataUrlToFile(dataUrl, `${base}-${Date.now()}.png`);
|
||||
if (saved) toast(t("codeSnapshot.saved"));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("codeSnapshot.failed", { message: e?.message || String(e) }), 5000);
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const previewVisible = computed(() => open.value && !!props.source);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="max-h-[86vh] overflow-y-auto border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[860px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Camera class="h-5 w-5 text-primary" />
|
||||
{{ t("codeSnapshot.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="previewVisible" class="flex flex-col gap-4 md:flex-row">
|
||||
<!-- Snapshot preview -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div ref="previewWrapRef" class="max-h-[56vh] overflow-auto rounded-md border bg-muted/30 p-3">
|
||||
<div v-html="snapshotHtml" class="inline-block min-w-[320px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="w-full shrink-0 space-y-3 md:w-52">
|
||||
<div class="grid gap-1.5">
|
||||
<Label class="text-xs">{{ t("codeSnapshot.theme") }}</Label>
|
||||
<div class="flex gap-1.5">
|
||||
<Button size="sm" variant="outline" class="h-7 flex-1 gap-1 text-[11px]" :class="{ 'border-primary text-primary': appearance === 'light' }" @click="appearance = 'light'">
|
||||
<Sun class="h-3 w-3" />
|
||||
{{ t("codeSnapshot.themeLight") }}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="h-7 flex-1 gap-1 text-[11px]" :class="{ 'border-primary text-primary': appearance === 'dark' }" @click="appearance = 'dark'">
|
||||
<Moon class="h-3 w-3" />
|
||||
{{ t("codeSnapshot.themeDark") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs">{{ t("codeSnapshot.windowControls") }}</Label>
|
||||
<Switch v-model:checked="showTrafficLights" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs">{{ t("codeSnapshot.lineNumbers") }}</Label>
|
||||
<Switch v-model:checked="showLineNumbers" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-1.5">
|
||||
<Label class="text-xs">{{ t("codeSnapshot.titleLabel") }}</Label>
|
||||
<Input v-model="title" class="h-8 text-xs" :placeholder="t('codeSnapshot.titlePlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="open = false">{{ t("codeSnapshot.close") }}</Button>
|
||||
<Button variant="outline" :disabled="exporting" @click="exportSnapshot('clipboard')">
|
||||
<Check v-if="copied" class="mr-1.5 h-4 w-4 text-green-500" />
|
||||
<ClipboardCopy v-else class="mr-1.5 h-4 w-4" />
|
||||
{{ copied ? t("codeSnapshot.copied") : t("codeSnapshot.copy") }}
|
||||
</Button>
|
||||
<Button :disabled="exporting" @click="exportSnapshot('file')">
|
||||
<Download class="mr-1.5 h-4 w-4" />
|
||||
{{ t("codeSnapshot.save") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ArrowRightLeft,
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Camera,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -44,6 +45,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import CodeSnapshotDialog from "@/components/codeSnapshot/CodeSnapshotDialog.vue";
|
||||
import type { CodeSnapshotSource } from "@/lib/codeSnapshot/codeSnapshot";
|
||||
import { useSettingsStore, AI_PROVIDER_PRESETS, normalizeAiConfig } from "@/stores/settingsStore";
|
||||
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -843,6 +846,14 @@ function sendProposalReply(positive: boolean) {
|
||||
const activePlaceholder = computed(() => `${t(`ai.placeholders.${activeAction.value}`)} ${t("ai.tableMentionPlaceholderHint")}`);
|
||||
const aiCodeAppearance = computed(() => (isDark.value ? "dark" : "light"));
|
||||
|
||||
const codeSnapshotOpen = ref(false);
|
||||
const codeSnapshotSource = ref<CodeSnapshotSource | null>(null);
|
||||
|
||||
function openCodeSnapshot(seg: { content: string; lang: string }) {
|
||||
codeSnapshotSource.value = { code: seg.content, lang: seg.lang };
|
||||
codeSnapshotOpen.value = true;
|
||||
}
|
||||
|
||||
const showActionButtons = computed(() => {
|
||||
if (!props.connection) return true;
|
||||
return !isVectorDbType(props.connection.db_type);
|
||||
@@ -2640,6 +2651,9 @@ async function openExternalUrl(url: string) {
|
||||
<Check v-if="copiedContentKey === `code:${i}:${j}`" class="h-3.5 w-3.5 text-green-400" />
|
||||
<Copy v-else class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('codeSnapshot.take')" @click="openCodeSnapshot(seg)">
|
||||
<Camera class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="ai-code-block whitespace-pre-wrap break-words p-3 text-xs leading-relaxed text-zinc-900 dark:text-zinc-100"><code v-html="seg.html"></code></pre>
|
||||
@@ -3116,6 +3130,7 @@ async function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CodeSnapshotDialog v-model:open="codeSnapshotOpen" :source="codeSnapshotSource" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
|
||||
import { AlignLeft, CaseLower, CaseUpper, ClipboardPaste, Code2, Download, FileCode, MessageSquareText, Minimize2, Pencil, PencilRuler, Play, Copy, List, Scissors, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
|
||||
import { AlignLeft, Camera, CaseLower, CaseUpper, ClipboardPaste, Code2, Download, FileCode, MessageSquareText, Minimize2, Pencil, PencilRuler, Play, Copy, List, Scissors, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { CompletionContext } from "@codemirror/autocomplete";
|
||||
import { Transaction, StateEffect } from "@codemirror/state";
|
||||
@@ -9,7 +9,9 @@ import { search as cmSearch } from "@codemirror/search";
|
||||
import EditorSearchPanel from "./EditorSearchPanel.vue";
|
||||
import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue";
|
||||
import DelimitedListDialog from "./DelimitedListDialog.vue";
|
||||
import CodeSnapshotDialog from "@/components/codeSnapshot/CodeSnapshotDialog.vue";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import type { CodeSnapshotSource } from "@/lib/codeSnapshot/codeSnapshot";
|
||||
import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
import { completionMatchRanges } from "@/lib/common/completionMatch";
|
||||
import { executionCandidateForMode, resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
|
||||
@@ -368,6 +370,8 @@ const pickerAnchor = ref<{ left: number; top: number }>();
|
||||
// Delimited list dialog state
|
||||
const delimitedListOpen = ref(false);
|
||||
const delimitedListSelectedText = ref("");
|
||||
const codeSnapshotOpen = ref(false);
|
||||
const codeSnapshotSource = ref<CodeSnapshotSource | null>(null);
|
||||
|
||||
function openDelimitedListDialog() {
|
||||
if (props.readOnly) return;
|
||||
@@ -1772,6 +1776,17 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
icon: Copy,
|
||||
shortcut: "Mod+C",
|
||||
},
|
||||
{
|
||||
label: t("editor.contextMenu.screenshotSelection"),
|
||||
action: () => {
|
||||
if (selectedSql.value.trim()) {
|
||||
codeSnapshotSource.value = { code: selectedSql.value, lang: "sql" };
|
||||
codeSnapshotOpen.value = true;
|
||||
}
|
||||
},
|
||||
disabled: !canCopySelectedSql.value,
|
||||
icon: Camera,
|
||||
},
|
||||
{
|
||||
label: t("editor.contextMenu.cutSelection"),
|
||||
action: cutSelectedSqlFromContextMenu,
|
||||
@@ -5843,6 +5858,7 @@ defineExpose({
|
||||
<EditorSearchPanel ref="searchPanelRef" :view="view" />
|
||||
<SqlExecutionTargetPicker v-if="pickerVisible" :candidates="pickerCandidates" :active-index="pickerActiveIndex" :anchor="pickerAnchor" @update:active-index="onPickerActiveIndexChange" @confirm="onPickerConfirm" @cancel="closePicker" />
|
||||
<DelimitedListDialog v-model:open="delimitedListOpen" :selected-text="delimitedListSelectedText" @confirm="applyDelimitedListResult" />
|
||||
<CodeSnapshotDialog v-model:open="codeSnapshotOpen" :source="codeSnapshotSource" />
|
||||
<!-- SQL 意图操作弹出菜单(参考 DataGrip Alt+Enter) -->
|
||||
<Teleport to="body">
|
||||
<div v-if="intentionPopup?.visible" class="intention-popup-overlay" @click.self="closeIntentionPopup">
|
||||
|
||||
Reference in new issue
Block a user