feat(redis): custom hover scrollbar for key and value views
This commit is contained in:
12 files changed
+204
-20
No files matched your search
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
const HIDE_DELAY = 800;
|
||||
|
||||
const scrollRef = ref<HTMLElement>();
|
||||
const trackRef = ref<HTMLElement>();
|
||||
const thumbRef = ref<HTMLElement>();
|
||||
const hasOverflow = ref(false);
|
||||
const isScrolling = ref(false);
|
||||
const isDragging = ref(false);
|
||||
const thumbStyle = ref({ insetInlineStart: "0%", width: "100%" });
|
||||
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let dragState: {
|
||||
pointerOffset: number;
|
||||
trackLeft: number;
|
||||
trackWidth: number;
|
||||
thumbWidth: number;
|
||||
maxScrollLeft: number;
|
||||
} | null = null;
|
||||
|
||||
function clearHideTimer() {
|
||||
if (!hideTimer) return;
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = null;
|
||||
}
|
||||
|
||||
function scheduleHide() {
|
||||
clearHideTimer();
|
||||
hideTimer = setTimeout(() => {
|
||||
isScrolling.value = false;
|
||||
hideTimer = null;
|
||||
}, HIDE_DELAY);
|
||||
}
|
||||
|
||||
function updateMetrics() {
|
||||
const element = scrollRef.value;
|
||||
if (!element) return;
|
||||
|
||||
const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
|
||||
hasOverflow.value = maxScrollLeft > 1;
|
||||
if (!hasOverflow.value) {
|
||||
thumbStyle.value = { insetInlineStart: "0%", width: "100%" };
|
||||
return;
|
||||
}
|
||||
|
||||
const thumbWidth = Math.min(100, Math.max(10, (element.clientWidth / element.scrollWidth) * 100));
|
||||
const thumbTravel = Math.max(0, 100 - thumbWidth);
|
||||
thumbStyle.value = {
|
||||
insetInlineStart: `${(element.scrollLeft / maxScrollLeft) * thumbTravel}%`,
|
||||
width: `${thumbWidth}%`,
|
||||
};
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
updateMetrics();
|
||||
isScrolling.value = true;
|
||||
scheduleHide();
|
||||
}
|
||||
|
||||
function onTrackPointerDown(event: PointerEvent) {
|
||||
const element = scrollRef.value;
|
||||
const track = trackRef.value;
|
||||
const thumb = thumbRef.value;
|
||||
if (!element || !track || !thumb || !hasOverflow.value) return;
|
||||
|
||||
event.preventDefault();
|
||||
const trackRect = track.getBoundingClientRect();
|
||||
const thumbRect = thumb.getBoundingClientRect();
|
||||
const pointerOnThumb = event.target === thumb || thumb.contains(event.target as Node);
|
||||
dragState = {
|
||||
pointerOffset: pointerOnThumb ? event.clientX - thumbRect.left : thumbRect.width / 2,
|
||||
trackLeft: trackRect.left,
|
||||
trackWidth: trackRect.width,
|
||||
thumbWidth: thumbRect.width,
|
||||
maxScrollLeft: element.scrollWidth - element.clientWidth,
|
||||
};
|
||||
isDragging.value = true;
|
||||
isScrolling.value = true;
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", stopDragging);
|
||||
window.addEventListener("pointercancel", stopDragging);
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!dragState || !scrollRef.value) return;
|
||||
const travel = Math.max(1, dragState.trackWidth - dragState.thumbWidth);
|
||||
const thumbLeft = Math.min(Math.max(event.clientX - dragState.trackLeft - dragState.pointerOffset, 0), travel);
|
||||
scrollRef.value.scrollLeft = (thumbLeft / travel) * dragState.maxScrollLeft;
|
||||
updateMetrics();
|
||||
}
|
||||
|
||||
function stopDragging() {
|
||||
dragState = null;
|
||||
isDragging.value = false;
|
||||
scheduleHide();
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", stopDragging);
|
||||
window.removeEventListener("pointercancel", stopDragging);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resizeObserver = new ResizeObserver(updateMetrics);
|
||||
if (scrollRef.value) resizeObserver.observe(scrollRef.value);
|
||||
void nextTick(updateMetrics);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopDragging();
|
||||
clearHideTimer();
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="redis-horizontal-scroll-host" :class="{ 'is-scrolling': isScrolling, 'is-dragging': isDragging }">
|
||||
<div ref="scrollRef" class="redis-format-tabs-scroll flex max-w-full overflow-x-auto rounded-md border bg-muted/20 p-0.5" @scroll="onScroll" @mouseenter="updateMetrics">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="hasOverflow" ref="trackRef" class="redis-format-tabs-scrollbar" @pointerdown="onTrackPointerDown">
|
||||
<div class="redis-format-tabs-scrollbar__thumb" :style="thumbStyle" ref="thumbRef" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.redis-horizontal-scroll-host {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.redis-format-tabs-scroll {
|
||||
scrollbar-width: none;
|
||||
scrollbar-gutter: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.redis-format-tabs-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.redis-format-tabs-scrollbar {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 1px;
|
||||
z-index: 2;
|
||||
height: 7px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.redis-horizontal-scroll-host:hover .redis-format-tabs-scrollbar,
|
||||
.redis-horizontal-scroll-host.is-scrolling .redis-format-tabs-scrollbar,
|
||||
.redis-horizontal-scroll-host.is-dragging .redis-format-tabs-scrollbar {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.redis-format-tabs-scrollbar::before,
|
||||
.redis-format-tabs-scrollbar__thumb {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.redis-format-tabs-scrollbar::before {
|
||||
content: "";
|
||||
inset-inline: 0;
|
||||
background: color-mix(in oklch, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.redis-format-tabs-scrollbar__thumb {
|
||||
min-width: 20px;
|
||||
background: color-mix(in oklch, var(--foreground) 38%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -2382,7 +2382,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
<template #default="{ item: row }">
|
||||
<CustomContextMenu :items="redisKeyContextMenuItems(row.node)" v-slot="{ onContextMenu, isOpen }">
|
||||
<div
|
||||
class="flex items-center gap-2 border-b px-3 text-[13px] cursor-pointer select-none group"
|
||||
class="flex items-center gap-2 border-b px-1.5 text-[13px] cursor-pointer select-none group"
|
||||
:class="[
|
||||
isOpen || (row.node.kind === 'leaf' && selectedKeyRaw === row.node.keyRaw) ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40',
|
||||
row.node.kind === 'leaf' ? (isLeafChecked(row.node.keyRaw) && selectedKeyRaw !== row.node.keyRaw ? 'bg-primary/10' : undefined) : groupSelectedCount(row.node) > 0 ? 'bg-primary/10' : undefined,
|
||||
@@ -2391,7 +2391,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
@click="onRowClick(row.node, $event)"
|
||||
@contextmenu="(event) => onRedisRowContextMenu(event, row.node, onContextMenu)"
|
||||
>
|
||||
<div class="min-w-0 flex flex-1 items-center gap-1 overflow-hidden" :style="{ paddingLeft: `${12 + row.depth * 16}px` }">
|
||||
<div class="min-w-0 flex flex-1 items-center gap-1 overflow-hidden" :style="{ paddingLeft: `${4 + row.depth * 10}px` }">
|
||||
<template v-if="row.node.kind === 'group'">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
import { decompressRedisValue, decodeBase64RedisValue, decodePickle, decodeProtobuf, isGzipMagic, type RedisDecompressAlgorithm } from "@/lib/redis/codec";
|
||||
import { canFullHighlightRedisText, findRedisTextMatches, nextRedisSearchMatchIndex, REDIS_VALUE_SEARCH_MATCH_LIMIT, renderRedisTextSearchHtml, redisValueSearchStatus } from "@/lib/redis/redisValueSearch";
|
||||
import TextContentSearchBar from "@/components/common/TextContentSearchBar.vue";
|
||||
import RedisHorizontalScrollbar from "@/components/redis/RedisHorizontalScrollbar.vue";
|
||||
import { decodeJsonUnicodeEscapes, formatJsonSource, mapDisplayToRaw } from "@/lib/common/safeJsonFormat";
|
||||
import { unixSecondsToCalendarDateTime } from "@/components/ui/date-time-picker/dateTimePicker";
|
||||
import { applyRedisExpiryPolicy, type RedisExpiryMode, redisExpiryModeForTtl, validateRedisExpiry } from "@/lib/redis/redisExpiry";
|
||||
@@ -97,7 +98,6 @@ const REDIS_AUTO_REFRESH_INTERVAL_STORAGE_KEY = "dbx-redis-auto-refresh-interval
|
||||
const REDIS_AUTO_REFRESH_INTERVAL_OPTIONS = [1, 3, 5, 10] as const;
|
||||
const REDIS_COLLECTION_ROW_HEIGHT = 32;
|
||||
const REDIS_STREAM_MIN_ROW_HEIGHT = 96;
|
||||
|
||||
const data = ref<RedisValue | null>(null);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
@@ -2804,7 +2804,7 @@ defineExpose({ focusSearch });
|
||||
<div v-if="isStringLikeKind && stringValueDetail" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex h-9 items-center gap-2 border-b px-4 text-xs shrink-0">
|
||||
<span class="shrink-0 text-muted-foreground">{{ t("redis.codecRowLabel") }}</span>
|
||||
<div class="flex max-w-full overflow-x-auto rounded-md border bg-muted/20 p-0.5">
|
||||
<RedisHorizontalScrollbar>
|
||||
<Button
|
||||
v-for="codec in REDIS_VALUE_CODEC_ORDER"
|
||||
:key="codec"
|
||||
@@ -2818,7 +2818,7 @@ defineExpose({ focusSearch });
|
||||
>
|
||||
{{ redisCodecLabel(codec) }}
|
||||
</Button>
|
||||
</div>
|
||||
</RedisHorizontalScrollbar>
|
||||
<FileArchive v-if="!isStringValueTruncated && stringGzipBadge && stringValueCodec === 'none'" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')" />
|
||||
<span class="flex-1" />
|
||||
<label v-if="isTextRedisFormat(stringValueView) || activeStructuredStringDetail || isDecompressCodec(stringValueCodec)" class="flex items-center gap-1.5 text-muted-foreground">
|
||||
@@ -2829,7 +2829,7 @@ defineExpose({ focusSearch });
|
||||
</div>
|
||||
<div class="flex h-9 items-center gap-2 border-b px-4 text-xs shrink-0">
|
||||
<span class="shrink-0 text-muted-foreground">{{ t("redis.viewRowLabel") }}</span>
|
||||
<div class="flex max-w-full overflow-x-auto rounded-md border bg-muted/20 p-0.5">
|
||||
<RedisHorizontalScrollbar>
|
||||
<Button
|
||||
v-for="format in REDIS_VALUE_FORMAT_DISPLAY_ORDER"
|
||||
:key="format"
|
||||
@@ -2842,7 +2842,7 @@ defineExpose({ focusSearch });
|
||||
>
|
||||
{{ redisFormatLabel(format, stringValueDetail.rawLabel) }}
|
||||
</Button>
|
||||
</div>
|
||||
</RedisHorizontalScrollbar>
|
||||
<span class="flex-1" />
|
||||
<div v-if="stringValueView === 'json' && stringValueDetail.json && stringValueCodec === 'none'" class="flex shrink-0 overflow-hidden rounded-md border bg-muted/20 p-0.5">
|
||||
<Button variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': !redisJsonDecoded }" @click="setRedisJsonUnicodeMode('raw')">{{ t("redis.jsonViewRaw") }}</Button>
|
||||
@@ -3584,11 +3584,11 @@ defineExpose({ focusSearch });
|
||||
<template v-else>
|
||||
<div class="flex h-9 items-center gap-2 border-b px-5 text-xs">
|
||||
<span class="shrink-0 text-muted-foreground">{{ t("redis.codecRowLabel") }}</span>
|
||||
<div class="flex max-w-full overflow-x-auto rounded-md border bg-muted/20 p-0.5">
|
||||
<RedisHorizontalScrollbar>
|
||||
<Button v-for="codec in REDIS_VALUE_CODEC_ORDER" :key="codec" variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': memberValueCodec === codec }" @click="setMemberValueCodec(codec)">
|
||||
{{ redisCodecLabel(codec) }}
|
||||
</Button>
|
||||
</div>
|
||||
</RedisHorizontalScrollbar>
|
||||
<span class="flex-1" />
|
||||
<label v-if="isTextRedisFormat(memberValueView) || activeStructuredMemberDetail || isDecompressCodec(memberValueCodec)" class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<WrapText class="h-3.5 w-3.5" />
|
||||
@@ -3598,7 +3598,7 @@ defineExpose({ focusSearch });
|
||||
</div>
|
||||
<div class="flex h-9 items-center gap-2 border-b px-5 text-xs">
|
||||
<span class="shrink-0 text-muted-foreground">{{ t("redis.viewRowLabel") }}</span>
|
||||
<div class="flex max-w-full overflow-x-auto rounded-md border bg-muted/20 p-0.5">
|
||||
<RedisHorizontalScrollbar>
|
||||
<Button
|
||||
v-for="format in REDIS_VALUE_FORMAT_DISPLAY_ORDER"
|
||||
:key="format"
|
||||
@@ -3611,7 +3611,7 @@ defineExpose({ focusSearch });
|
||||
>
|
||||
{{ redisFormatLabel(format, selectedMemberDetail.rawLabel) }}
|
||||
</Button>
|
||||
</div>
|
||||
</RedisHorizontalScrollbar>
|
||||
<span class="flex-1" />
|
||||
<div v-if="memberValueView === 'json' && selectedMemberDetail.json && memberValueCodec === 'none'" class="flex shrink-0 overflow-hidden rounded-md border bg-muted/20 p-0.5">
|
||||
<Button variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': !redisJsonDecoded }" @click="setRedisJsonUnicodeMode('raw')">{{ t("redis.jsonViewRaw") }}</Button>
|
||||
|
||||
Reference in new issue
Block a user