feat(data-grid): add customizable data type colors

This commit is contained in:
kirito authored and GitHub committed 2026-08-18 23:05:17 +08:00
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>