feat(data-grid): add customizable data type colors
This commit is contained in:
1 parent
95760e320a
commit
e0448ac87e
25 files changed
+1209
-15
No files matched your search
@@ -4,7 +4,7 @@ import type { Ref } from "vue";
|
||||
import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { AlertTriangle, ArrowLeft, Check, CheckCircle2, CircleHelp, Cloud, Copy, Download, ExternalLink, GripVertical, Loader2, Moon, PackageSearch, Pencil, Plus, RefreshCw, RotateCcw, Search, Settings, Sun, SunMoon, Terminal, Trash2, Upload, X } from "@lucide/vue";
|
||||
import { AlertTriangle, ArrowLeft, Check, CheckCircle2, CircleHelp, Cloud, Copy, Download, ExternalLink, GripVertical, Loader2, Moon, PackageSearch, Palette, Pencil, Plus, RefreshCw, RotateCcw, Search, Settings, Sun, SunMoon, Terminal, Trash2, Upload, X } from "@lucide/vue";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -58,6 +58,8 @@ import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
|
||||
import { isAiConnectionTestConfigCurrent } from "@/lib/ai/aiConnectionTest";
|
||||
import { MAX_AGENT_TURNS_DEFAULT, MAX_AGENT_TURNS_MAX, MAX_AGENT_TURNS_MIN, maxAgentTurnsOutOfRange, normalizeMaxAgentTurns } from "@/lib/ai/maxAgentTurns";
|
||||
import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue";
|
||||
import DataGridTypeColorSchemeDialog from "@/components/grid/DataGridTypeColorSchemeDialog.vue";
|
||||
import { DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID, type DataGridTypeColorScheme } from "@/lib/dataGrid/dataGridTypeColorScheme";
|
||||
import TunnelProfileManager from "@/components/connection/TunnelProfileManager.vue";
|
||||
import DangerConfirmDialog from "./DangerConfirmDialog.vue";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
@@ -305,7 +307,10 @@ const editUiScale = ref(settingsStore.editorSettings.uiScale);
|
||||
const editTheme = ref(settingsStore.editorSettings.theme);
|
||||
const editCustomThemes = ref<CustomTheme[]>([...settingsStore.editorSettings.customThemes]);
|
||||
const editActiveCustomThemeId = ref(settingsStore.editorSettings.activeCustomThemeId);
|
||||
const editDataGridTypeColorSchemes = ref<DataGridTypeColorScheme[]>(structuredClone(settingsStore.editorSettings.dataGridTypeColorSchemes));
|
||||
const editActiveDataGridTypeColorSchemeId = ref(settingsStore.editorSettings.activeDataGridTypeColorSchemeId);
|
||||
const showThemeCustomizer = ref(false);
|
||||
const showDataGridTypeColorScheme = ref(false);
|
||||
const editExecuteMode = ref(settingsStore.editorSettings.executeMode);
|
||||
const editShortcuts = ref(normalizeShortcutSettings(settingsStore.editorSettings.shortcuts));
|
||||
function translateWithExecuteShortcut(key: string): string {
|
||||
@@ -533,6 +538,8 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
|
||||
showColumnTypesInHeader: editShowColumnTypesInHeader.value,
|
||||
dataGridShowTransposeFieldMetadata: editDataGridShowTransposeFieldMetadata.value,
|
||||
colorizeDataGridCellTypes: editColorizeDataGridCellTypes.value,
|
||||
dataGridTypeColorSchemes: editDataGridTypeColorSchemes.value,
|
||||
activeDataGridTypeColorSchemeId: editActiveDataGridTypeColorSchemeId.value,
|
||||
showIndexIndicatorsInHeader: editShowIndexIndicatorsInHeader.value,
|
||||
compactColumnHeaderActions: editCompactColumnHeaderActions.value,
|
||||
dataGridQuickEntry: editDataGridQuickEntry.value,
|
||||
@@ -816,6 +823,8 @@ function syncEditorSettingsDraftFromStore() {
|
||||
editShowColumnTypesInHeader.value = settingsStore.editorSettings.showColumnTypesInHeader;
|
||||
editDataGridShowTransposeFieldMetadata.value = settingsStore.editorSettings.dataGridShowTransposeFieldMetadata;
|
||||
editColorizeDataGridCellTypes.value = settingsStore.editorSettings.colorizeDataGridCellTypes;
|
||||
editDataGridTypeColorSchemes.value = structuredClone(settingsStore.editorSettings.dataGridTypeColorSchemes);
|
||||
editActiveDataGridTypeColorSchemeId.value = settingsStore.editorSettings.activeDataGridTypeColorSchemeId;
|
||||
editShowIndexIndicatorsInHeader.value = settingsStore.editorSettings.showIndexIndicatorsInHeader;
|
||||
editCompactColumnHeaderActions.value = settingsStore.editorSettings.compactColumnHeaderActions;
|
||||
editDataGridQuickEntry.value = settingsStore.editorSettings.dataGridQuickEntry;
|
||||
@@ -1098,6 +1107,8 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
||||
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
|
||||
editDataGridShowTransposeFieldMetadata.value = DEFAULT_EDITOR_SETTINGS.dataGridShowTransposeFieldMetadata;
|
||||
editColorizeDataGridCellTypes.value = DEFAULT_EDITOR_SETTINGS.colorizeDataGridCellTypes;
|
||||
// Back to the built-in palette, but keep the user's saved schemes available.
|
||||
editActiveDataGridTypeColorSchemeId.value = DEFAULT_EDITOR_SETTINGS.activeDataGridTypeColorSchemeId;
|
||||
editShowIndexIndicatorsInHeader.value = DEFAULT_EDITOR_SETTINGS.showIndexIndicatorsInHeader;
|
||||
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
|
||||
editDataGridQuickEntry.value = DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry;
|
||||
@@ -1172,6 +1183,8 @@ function resetAllDefaults() {
|
||||
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
|
||||
editDataGridShowTransposeFieldMetadata.value = DEFAULT_EDITOR_SETTINGS.dataGridShowTransposeFieldMetadata;
|
||||
editColorizeDataGridCellTypes.value = DEFAULT_EDITOR_SETTINGS.colorizeDataGridCellTypes;
|
||||
// Reset the selection only; saved schemes survive a full settings reset.
|
||||
editActiveDataGridTypeColorSchemeId.value = DEFAULT_EDITOR_SETTINGS.activeDataGridTypeColorSchemeId;
|
||||
editShowIndexIndicatorsInHeader.value = DEFAULT_EDITOR_SETTINGS.showIndexIndicatorsInHeader;
|
||||
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
|
||||
editDataGridQuickEntry.value = DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry;
|
||||
@@ -1409,6 +1422,16 @@ function handleThemeSave(updatedThemes: CustomTheme[], activeId: string) {
|
||||
showThemeCustomizer.value = false;
|
||||
}
|
||||
|
||||
function handleDataGridTypeColorSchemeChange(schemes: DataGridTypeColorScheme[], activeId: string) {
|
||||
editDataGridTypeColorSchemes.value = structuredClone(schemes);
|
||||
editActiveDataGridTypeColorSchemeId.value = activeId;
|
||||
}
|
||||
|
||||
const activeDataGridTypeColorSchemeName = computed(() => {
|
||||
if (editActiveDataGridTypeColorSchemeId.value === DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID) return t("settings.dataGridTypeColorSchemeAuto");
|
||||
return editDataGridTypeColorSchemes.value.find((scheme) => scheme.id === editActiveDataGridTypeColorSchemeId.value)?.name ?? t("settings.dataGridTypeColorSchemeAuto");
|
||||
});
|
||||
|
||||
function onDisconnectTabHandlingModeChange(v: any) {
|
||||
if (v === "close-tabs" || v === "keep-tabs-clear-results" || v === "keep-tabs-keep-results") {
|
||||
editDisconnectTabHandlingMode.value = v;
|
||||
@@ -4960,6 +4983,21 @@ onUnmounted(() => {
|
||||
<div class="text-sm font-medium text-muted-foreground">
|
||||
{{ t("settings.dataGridDisplay") }}
|
||||
</div>
|
||||
<div data-settings-search-id="data-grid-type-colors" :class="['flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('data-grid-type-colors')]">
|
||||
<div class="space-y-1 min-w-0">
|
||||
<Label>{{ t("settings.dataGridTypeColorScheme") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.dataGridTypeColorSchemeDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-xs text-muted-foreground">{{ activeDataGridTypeColorSchemeName }}</span>
|
||||
<Button variant="outline" class="h-9 w-auto px-4" @click="showDataGridTypeColorScheme = true">
|
||||
<Palette class="mr-2 h-4 w-4" />
|
||||
{{ t("settings.dataGridTypeColorSchemeConfigure") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="data-grid-filter-view">
|
||||
@@ -6990,6 +7028,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Theme Customizer Dialog -->
|
||||
<ThemeCustomizerDialog v-model:open="showThemeCustomizer" :themes="editCustomThemes" :active-theme-id="editActiveCustomThemeId" @save="handleThemeSave" />
|
||||
<DataGridTypeColorSchemeDialog v-model:open="showDataGridTypeColorScheme" :schemes="editDataGridTypeColorSchemes" :active-scheme-id="editActiveDataGridTypeColorSchemeId" @change="handleDataGridTypeColorSchemeChange" />
|
||||
|
||||
<!-- Snippet Add/Edit Dialog -->
|
||||
<Dialog :open="snippetDialogOpen" @update:open="snippetDialogOpen = $event">
|
||||
|
||||
@@ -88,6 +88,7 @@ import { uuid } from "@/lib/common/utils";
|
||||
import { generateCellValues, type CellValueGenerationKind } from "@/lib/dataGrid/cellValueGeneration";
|
||||
import { compactHeaderColumnType, isNumericColumnType, resolveDataGridTypeVisualKind, resolveHeaderColumnType, resolveResultColumnType } from "@/lib/dataGrid/dataGridColumnType";
|
||||
import { dataGridCellTextClass, dataGridTypeVisualClass } from "@/lib/dataGrid/dataGridCellTextVisual";
|
||||
import { DATA_GRID_TYPE_COLOR_KEYS, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
|
||||
import {
|
||||
canDeleteExistingTdengineRows,
|
||||
canEditExistingTableRows,
|
||||
@@ -5899,7 +5900,14 @@ const canvasSurfaceWidth = computed(() => {
|
||||
if (vw <= 0) return total;
|
||||
return Math.min(vw, total);
|
||||
});
|
||||
const canvasRenderStyleKey = computed(() => `${settingsStore.editorSettings.theme}:${settingsStore.editorSettings.uiScale}:${canvasBackingPixelRatio.value}:${isDark.value}:${themePalette.value}:${tableFontFamily.value}:${tableFontSize.value}:${!!saveError.value}`);
|
||||
// The canvas render state caches the resolved paint theme by this key, so the
|
||||
// active type palette has to take part or a recolor never repaints.
|
||||
const dataGridTypeColorKey = computed(() => {
|
||||
const settings = settingsStore.editorSettings;
|
||||
const colors = resolveActiveDataGridTypeColors(settings.dataGridTypeColorSchemes, settings.activeDataGridTypeColorSchemeId);
|
||||
return colors ? DATA_GRID_TYPE_COLOR_KEYS.map((key) => colors[key]).join(",") : "auto";
|
||||
});
|
||||
const canvasRenderStyleKey = computed(() => `${settingsStore.editorSettings.theme}:${settingsStore.editorSettings.uiScale}:${canvasBackingPixelRatio.value}:${isDark.value}:${themePalette.value}:${tableFontFamily.value}:${tableFontSize.value}:${!!saveError.value}:${dataGridTypeColorKey.value}`);
|
||||
const CANVAS_MOUSE_WHEEL_SCROLL_MULTIPLIER = 1.5;
|
||||
const CANVAS_TRACKPAD_DELTA_THRESHOLD = 40;
|
||||
let canvasPixelRatioMediaQuery: MediaQueryList | null = null;
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import ColorSpectrumPicker from "@/components/ui/ColorSpectrumPicker.vue";
|
||||
import { Copy, Pencil, Plus, RotateCcw, Trash2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID, type DataGridTypeColorKey, type DataGridTypeColorScheme, defaultDataGridTypeColors, normalizeDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
schemes: DataGridTypeColorScheme[];
|
||||
activeSchemeId: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", value: boolean): void;
|
||||
(e: "change", schemes: DataGridTypeColorScheme[], activeId: string): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const localSchemes = ref<DataGridTypeColorScheme[]>([]);
|
||||
const activeEditId = ref(DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID);
|
||||
const renamingId = ref<string | null>(null);
|
||||
const renamingName = ref("");
|
||||
function cloneSchemes(schemes: DataGridTypeColorScheme[]): DataGridTypeColorScheme[] {
|
||||
return schemes.map((scheme) => ({ ...scheme, colors: { ...scheme.colors } }));
|
||||
}
|
||||
|
||||
// Everything below edits this buffer only. Nothing reaches the settings store
|
||||
// until Done, so Cancel just drops it.
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (!isOpen) return;
|
||||
localSchemes.value = cloneSchemes(props.schemes);
|
||||
activeEditId.value = props.activeSchemeId;
|
||||
renamingId.value = null;
|
||||
},
|
||||
);
|
||||
|
||||
const typeColorItems: { key: DataGridTypeColorKey; labelKey: string; sample: string }[] = [
|
||||
{ key: "integer", labelKey: "settings.dataGridTypeColorInteger", sample: "42" },
|
||||
{ key: "numeric", labelKey: "settings.dataGridTypeColorNumeric", sample: "3.14" },
|
||||
{ key: "string", labelKey: "settings.dataGridTypeColorString", sample: "'text'" },
|
||||
{ key: "boolean", labelKey: "settings.dataGridTypeColorBoolean", sample: "true" },
|
||||
{ key: "temporal", labelKey: "settings.dataGridTypeColorTemporal", sample: "2026-08-17" },
|
||||
{ key: "structured", labelKey: "settings.dataGridTypeColorStructured", sample: '{"a": 1}' },
|
||||
{ key: "identifier", labelKey: "settings.dataGridTypeColorIdentifier", sample: "9f8e-4c1a" },
|
||||
{ key: "binary", labelKey: "settings.dataGridTypeColorBinary", sample: "0x1F8B" },
|
||||
{ key: "spatial", labelKey: "settings.dataGridTypeColorSpatial", sample: "POINT(0 0)" },
|
||||
];
|
||||
|
||||
/** The auto entry is a sentinel rather than a stored scheme, so it has no editable colors. */
|
||||
const isFollowingTheme = computed(() => activeEditId.value === DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID);
|
||||
|
||||
const activeScheme = computed(() => localSchemes.value.find((scheme) => scheme.id === activeEditId.value));
|
||||
|
||||
// Falls back to the built-in palette so the auto entry still previews real colors.
|
||||
const displayedColors = computed(() => activeScheme.value?.colors ?? defaultDataGridTypeColors(isDark.value));
|
||||
|
||||
function updateColor(key: DataGridTypeColorKey, value: string) {
|
||||
const scheme = activeScheme.value;
|
||||
if (!scheme) return;
|
||||
const trimmed = value.trim();
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(trimmed)) return;
|
||||
scheme.colors = { ...scheme.colors, [key]: trimmed.toLowerCase() };
|
||||
}
|
||||
|
||||
function nextSchemeName(): string {
|
||||
return `${t("settings.dataGridTypeColorSchemeDefaultName")} ${localSchemes.value.length + 1}`;
|
||||
}
|
||||
|
||||
function selectScheme(id: string) {
|
||||
activeEditId.value = id;
|
||||
}
|
||||
|
||||
function addScheme() {
|
||||
const id = `type-colors-${Date.now()}`;
|
||||
// Seed from whatever is on screen so a new scheme starts as a visible tweak.
|
||||
localSchemes.value.push({ id, name: nextSchemeName(), colors: { ...displayedColors.value } });
|
||||
activeEditId.value = id;
|
||||
}
|
||||
|
||||
function duplicateScheme() {
|
||||
const scheme = activeScheme.value;
|
||||
if (!scheme) return;
|
||||
const id = `type-colors-${Date.now()}`;
|
||||
localSchemes.value.push({ id, name: `${scheme.name}${t("settings.dataGridTypeColorSchemeCopySuffix")}`, colors: { ...scheme.colors } });
|
||||
activeEditId.value = id;
|
||||
}
|
||||
|
||||
function deleteScheme() {
|
||||
const scheme = activeScheme.value;
|
||||
if (!scheme) return;
|
||||
localSchemes.value = localSchemes.value.filter((entry) => entry.id !== scheme.id);
|
||||
activeEditId.value = DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID;
|
||||
}
|
||||
|
||||
function resetScheme() {
|
||||
const scheme = activeScheme.value;
|
||||
if (!scheme) return;
|
||||
scheme.colors = { ...defaultDataGridTypeColors(isDark.value) };
|
||||
}
|
||||
|
||||
function startRename() {
|
||||
const scheme = activeScheme.value;
|
||||
if (!scheme) return;
|
||||
renamingId.value = scheme.id;
|
||||
renamingName.value = scheme.name;
|
||||
}
|
||||
|
||||
function confirmRename() {
|
||||
const scheme = localSchemes.value.find((entry) => entry.id === renamingId.value);
|
||||
const name = renamingName.value.trim();
|
||||
if (scheme && name) scheme.name = name;
|
||||
renamingId.value = null;
|
||||
}
|
||||
|
||||
/** The single commit point: the edit buffer becomes the applied settings. */
|
||||
function handleDone() {
|
||||
emit(
|
||||
"change",
|
||||
cloneSchemes(localSchemes.value).map((scheme) => ({ ...scheme, colors: normalizeDataGridTypeColors(scheme.colors) })),
|
||||
activeEditId.value,
|
||||
);
|
||||
emit("update:open", false);
|
||||
}
|
||||
|
||||
/** Drops the edit buffer; the store never saw any of it. */
|
||||
function handleCancel() {
|
||||
emit("update:open", false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="props.open" @update:open="emit('update:open', $event)">
|
||||
<DialogContent class="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("settings.dataGridTypeColorSchemeTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.dataGridTypeColorSchemeDescription") }}
|
||||
</p>
|
||||
|
||||
<!-- Scheme selector + management -->
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1 min-w-0 space-y-2">
|
||||
<label class="text-sm font-medium">{{ t("settings.dataGridTypeColorScheme") }}</label>
|
||||
<Select :model-value="activeEditId" @update:model-value="selectScheme(String($event))">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID">
|
||||
{{ t("settings.dataGridTypeColorSchemeAuto") }}
|
||||
</SelectItem>
|
||||
<SelectItem v-for="scheme in localSchemes" :key="scheme.id" :value="scheme.id">
|
||||
{{ scheme.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="h-9" :title="t('settings.dataGridTypeColorSchemeAdd')" @click="addScheme">
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-9" :disabled="isFollowingTheme" :title="t('settings.dataGridTypeColorSchemeDuplicate')" @click="duplicateScheme">
|
||||
<Copy class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-9" :disabled="isFollowingTheme" :title="t('settings.dataGridTypeColorSchemeRename')" @click="startRename">
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-9" :disabled="isFollowingTheme" :title="t('settings.dataGridTypeColorSchemeDelete')" @click="deleteScheme">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="renamingId" class="flex items-center gap-2">
|
||||
<Input v-model="renamingName" class="h-8" @keydown.enter="confirmRename" />
|
||||
<Button size="sm" @click="confirmRename">{{ t("settings.dataGridTypeColorSchemeRenameConfirm") }}</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="isFollowingTheme" class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.dataGridTypeColorSchemeAutoHint") }}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" class="shrink-0" @click="addScheme">
|
||||
{{ t("settings.dataGridTypeColorSchemeCreateFromCurrent") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Color rows -->
|
||||
<div class="grid gap-2 sm:grid-cols-2" :class="{ 'opacity-60': isFollowingTheme }">
|
||||
<div v-for="item in typeColorItems" :key="item.key" class="flex items-center gap-2 rounded-md border px-2 py-1.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-xs font-medium">{{ t(item.labelKey) }}</div>
|
||||
<div class="truncate font-mono text-xs" :style="{ color: displayedColors[item.key] }">{{ item.sample }}</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="w-[74px] shrink-0 rounded border bg-transparent px-1.5 py-0.5 font-mono text-xs disabled:cursor-not-allowed"
|
||||
:value="displayedColors[item.key]"
|
||||
:disabled="isFollowingTheme"
|
||||
spellcheck="false"
|
||||
@change="updateColor(item.key, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<ColorSpectrumPicker :model-value="displayedColors[item.key]" :disabled="isFollowingTheme" :label="t(item.labelKey)" @update:model-value="updateColor(item.key, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="gap-2">
|
||||
<Button variant="outline" :disabled="isFollowingTheme" class="mr-auto" @click="resetScheme">
|
||||
<RotateCcw class="mr-2 h-4 w-4" />
|
||||
{{ t("settings.dataGridTypeColorSchemeReset") }}
|
||||
</Button>
|
||||
<Button variant="outline" @click="handleCancel">{{ t("settings.cancel") }}</Button>
|
||||
<Button @click="handleDone">{{ t("settings.dataGridTypeColorSchemeDone") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { hexToHsv, hsvToHex } from "@/lib/common/color";
|
||||
|
||||
interface Props {
|
||||
modelValue: string;
|
||||
disabled?: boolean;
|
||||
/** Shown next to the live hex readout so a row can label what is being picked. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{ (e: "update:modelValue", value: string): void }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const open = ref(false);
|
||||
const rootRef = ref<HTMLElement | null>(null);
|
||||
const spectrumRef = ref<HTMLElement | null>(null);
|
||||
const hueRef = ref<HTMLElement | null>(null);
|
||||
const hue = ref(0);
|
||||
const saturation = ref(0);
|
||||
const value = ref(0);
|
||||
// Non-null only while the cursor is over a gradient, so the readout can track it
|
||||
// without disturbing the committed color.
|
||||
const previewHex = ref<string | null>(null);
|
||||
|
||||
const currentHex = computed(() => hsvToHex({ h: hue.value, s: saturation.value, v: value.value }));
|
||||
// Hovering wins so the cursor can survey colors, otherwise show the pending pick.
|
||||
const readoutHex = computed(() => previewHex.value ?? currentHex.value);
|
||||
|
||||
function syncFromModel() {
|
||||
const hsv = hexToHsv(props.modelValue);
|
||||
if (!hsv) return;
|
||||
// A greyscale color carries no usable hue, so keep the slider where the user left it.
|
||||
if (hsv.s > 0) hue.value = hsv.h;
|
||||
saturation.value = hsv.s;
|
||||
value.value = hsv.v;
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, syncFromModel, { immediate: true });
|
||||
|
||||
function ratioWithin(element: HTMLElement, event: PointerEvent | MouseEvent): { x: number; y: number } {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
|
||||
y: Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
|
||||
};
|
||||
}
|
||||
|
||||
function spectrumHexAt(event: PointerEvent | MouseEvent): string | null {
|
||||
if (!spectrumRef.value) return null;
|
||||
const { x, y } = ratioWithin(spectrumRef.value, event);
|
||||
return hsvToHex({ h: hue.value, s: x, v: 1 - y });
|
||||
}
|
||||
|
||||
function hueHexAt(event: PointerEvent | MouseEvent): { hue: number; hex: string } | null {
|
||||
if (!hueRef.value) return null;
|
||||
const { x } = ratioWithin(hueRef.value, event);
|
||||
const nextHue = x * 360;
|
||||
return { hue: nextHue, hex: hsvToHex({ h: nextHue, s: saturation.value || 1, v: value.value || 1 }) };
|
||||
}
|
||||
|
||||
function onSpectrumMove(event: PointerEvent) {
|
||||
previewHex.value = spectrumHexAt(event);
|
||||
// Dragging with the button held keeps moving the pending marker; nothing is
|
||||
// handed to the parent until the confirm button is pressed.
|
||||
if (event.buttons === 1) onSpectrumPick(event);
|
||||
}
|
||||
|
||||
function onSpectrumPick(event: PointerEvent | MouseEvent) {
|
||||
if (!spectrumRef.value) return;
|
||||
const { x, y } = ratioWithin(spectrumRef.value, event);
|
||||
saturation.value = x;
|
||||
value.value = 1 - y;
|
||||
}
|
||||
|
||||
function onHueMove(event: PointerEvent) {
|
||||
previewHex.value = hueHexAt(event)?.hex ?? null;
|
||||
if (event.buttons === 1) onHuePick(event);
|
||||
}
|
||||
|
||||
function onHuePick(event: PointerEvent | MouseEvent) {
|
||||
const picked = hueHexAt(event);
|
||||
if (!picked) return;
|
||||
hue.value = picked.hue;
|
||||
if (saturation.value === 0) saturation.value = 1;
|
||||
if (value.value === 0) value.value = 1;
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
previewHex.value = null;
|
||||
}
|
||||
|
||||
/** The only path that hands a value to the parent. */
|
||||
function confirmPick() {
|
||||
emit("update:modelValue", currentHex.value);
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function cancelPick() {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function onHexInput(raw: string) {
|
||||
const trimmed = raw.trim();
|
||||
if (!/^#?[0-9a-fA-F]{6}$/.test(trimmed)) return;
|
||||
const hex = trimmed.startsWith("#") ? trimmed.toLowerCase() : `#${trimmed.toLowerCase()}`;
|
||||
const hsv = hexToHsv(hex);
|
||||
if (!hsv) return;
|
||||
if (hsv.s > 0) hue.value = hsv.h;
|
||||
saturation.value = hsv.s;
|
||||
value.value = hsv.v;
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
if (!open.value || !rootRef.value) return;
|
||||
if (!rootRef.value.contains(event.target as Node)) open.value = false;
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (typeof document === "undefined") return;
|
||||
if (isOpen) {
|
||||
syncFromModel();
|
||||
document.addEventListener("pointerdown", onDocumentPointerDown, true);
|
||||
} else {
|
||||
clearPreview();
|
||||
document.removeEventListener("pointerdown", onDocumentPointerDown, true);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof document !== "undefined") document.removeEventListener("pointerdown", onDocumentPointerDown, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="relative">
|
||||
<button type="button" class="h-6 w-6 shrink-0 rounded border border-border/60 disabled:cursor-not-allowed disabled:opacity-60" :style="{ backgroundColor: props.modelValue }" :disabled="props.disabled" :aria-label="props.label" @click="open = !open" />
|
||||
|
||||
<div v-if="open" class="absolute right-0 top-full z-50 mt-1 w-[224px] rounded-lg border bg-popover p-2 shadow-lg" @pointerleave="clearPreview">
|
||||
<!-- Saturation / value gradient for the active hue -->
|
||||
<div ref="spectrumRef" class="relative h-[132px] w-full cursor-crosshair rounded" :style="{ backgroundColor: `hsl(${hue}, 100%, 50%)` }" @pointermove="onSpectrumMove" @pointerdown.prevent="onSpectrumPick" @pointerleave="clearPreview">
|
||||
<div class="pointer-events-none absolute inset-0 rounded" style="background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0))" />
|
||||
<div class="pointer-events-none absolute inset-0 rounded" style="background: linear-gradient(to top, #000, rgba(0, 0, 0, 0))" />
|
||||
<div class="pointer-events-none absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow" :style="{ left: `${saturation * 100}%`, top: `${(1 - value) * 100}%` }" />
|
||||
</div>
|
||||
|
||||
<!-- Hue strip -->
|
||||
<div ref="hueRef" class="relative mt-2 h-3 w-full cursor-crosshair rounded" style="background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)" @pointermove="onHueMove" @pointerdown.prevent="onHuePick" @pointerleave="clearPreview">
|
||||
<div class="pointer-events-none absolute top-1/2 h-4 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-sm border border-white shadow" :style="{ left: `${(hue / 360) * 100}%` }" />
|
||||
</div>
|
||||
|
||||
<!-- Live readout: follows the cursor, falls back to the pending pick -->
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<div class="h-6 w-6 shrink-0 rounded border border-border/60" :style="{ backgroundColor: readoutHex }" />
|
||||
<input type="text" class="w-full min-w-0 rounded border bg-transparent px-1.5 py-0.5 font-mono text-xs" :value="readoutHex" spellcheck="false" @change="onHexInput(($event.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center justify-end gap-1.5">
|
||||
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" @click="cancelPick">{{ t("settings.cancel") }}</Button>
|
||||
<Button size="sm" class="h-7 px-2 text-xs" @click="confirmPick">{{ t("common.confirm") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user