feat(grid): preview binary image cell values
This commit is contained in:
15 files changed
+352
-23
No files matched your search
@@ -3482,6 +3482,7 @@ function cellDetailFor(rowIndex: number, columnIndex: number): DataGridCellDetai
|
||||
columns: props.result.columns,
|
||||
columnIndex,
|
||||
typeByColumn: columnTypeMap.value,
|
||||
resultColumnTypes: props.result.column_types,
|
||||
commentByColumn: columnCommentMap.value,
|
||||
displayValue: (value, index) => formatCellCached(value, index),
|
||||
isEditable: canEditCellItem(item, columnIndex),
|
||||
@@ -3509,6 +3510,7 @@ const rowDetail = computed(() => {
|
||||
columns: props.result.columns,
|
||||
columnIndexes: visibleColumnIndexes.value,
|
||||
typeByColumn: columnTypeMap.value,
|
||||
resultColumnTypes: props.result.column_types,
|
||||
commentByColumn: columnCommentMap.value,
|
||||
displayValue: (value, index) => formatCellCached(value, index),
|
||||
isEditableColumn: (columnIndex) => canEditCellItem(item, columnIndex),
|
||||
@@ -3528,6 +3530,7 @@ const columnDetail = computed(() => {
|
||||
columns: props.result.columns,
|
||||
columnIndex,
|
||||
typeByColumn: columnTypeMap.value,
|
||||
resultColumnTypes: props.result.column_types,
|
||||
commentByColumn: columnCommentMap.value,
|
||||
displayValue: (value, index) => formatCellCached(value, index),
|
||||
});
|
||||
@@ -8437,7 +8440,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="space-y-1" :class="[{ 'min-h-0 flex flex-col': cellDetailPanelIsBottom || isEditingDetail }, cellDetailPanelIsBottom ? 'flex-1' : '']">
|
||||
<div class="space-y-1" :class="[{ 'min-h-0 flex flex-col': cellDetailPanelIsBottom || isEditingDetail }, cellDetailPanelIsBottom && !(activeCellDetail.imagePreviewUrl && !isEditingDetail) ? 'flex-1' : '', activeCellDetail.imagePreviewUrl && !isEditingDetail ? 'shrink-0' : '']">
|
||||
<div class="flex min-h-5 items-center justify-between gap-2">
|
||||
<div class="text-muted-foreground">{{ t("grid.cellValue") }}</div>
|
||||
<div v-if="!isEditingDetail" class="flex items-center gap-1">
|
||||
@@ -8476,14 +8479,14 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="activeCellDetail.imagePreviewUrl && !isEditingDetail" class="space-y-1.5">
|
||||
<div v-if="activeCellDetail.imagePreviewUrl && !isEditingDetail" class="shrink-0 space-y-1.5">
|
||||
<div class="text-muted-foreground">{{ t("grid.imagePreview") }}</div>
|
||||
<a :href="activeCellDetail.imagePreviewUrl" role="button" class="block overflow-hidden rounded border bg-muted/20" @click.prevent="openImagePreview(activeCellDetail.imagePreviewUrl, activeCellDetail.column)">
|
||||
<img :src="activeCellDetail.imagePreviewUrl" :alt="activeCellDetail.column" loading="lazy" decoding="async" referrerpolicy="no-referrer" class="max-h-72 w-full object-contain" />
|
||||
<a :href="activeCellDetail.imagePreviewUrl" role="button" class="flex overflow-hidden rounded border bg-muted/20" :class="cellDetailPanelIsBottom ? 'max-h-28' : 'max-h-40'" @click.prevent="openImagePreview(activeCellDetail.imagePreviewUrl, activeCellDetail.column)">
|
||||
<img :src="activeCellDetail.imagePreviewUrl" :alt="activeCellDetail.column" loading="lazy" decoding="async" referrerpolicy="no-referrer" class="max-h-full w-full object-contain" />
|
||||
</a>
|
||||
</div>
|
||||
<template v-if="isEditingDetail">
|
||||
<div class="min-h-0" :class="cellDetailPanelIsBottom ? 'flex-1' : ''" :style="sideDetailEditorStyle">
|
||||
<div class="min-h-32 shrink-0" :class="cellDetailPanelIsBottom ? 'h-32' : ''" :style="sideDetailEditorStyle">
|
||||
<TemporalCellEditor v-if="detailTemporalEditorKind" v-model="detailEditValue" :kind="detailTemporalEditorKind" variant="inline" :commit-on-close="false" @cancel="cancelDetailEdit" @commit="commitDetailEdit" />
|
||||
<div v-else ref="detailsEditorContainer" data-cell-detail-editor-root class="min-h-0 h-full w-full rounded border overflow-hidden" />
|
||||
</div>
|
||||
@@ -8507,7 +8510,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
<pre
|
||||
v-else
|
||||
class="overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words cursor-pointer hover:border-primary/50"
|
||||
:class="[{ 'cursor-text': activeCellDetail.isEditable }, cellDetailPanelIsBottom ? 'min-h-0 flex-1' : '']"
|
||||
:class="[{ 'cursor-text': activeCellDetail.isEditable }, cellDetailPanelIsBottom && activeCellDetail.imagePreviewUrl ? 'min-h-24 max-h-32 shrink-0' : '', cellDetailPanelIsBottom && !activeCellDetail.imagePreviewUrl ? 'min-h-0 flex-1' : '']"
|
||||
@dblclick="startDetailEdit"
|
||||
>{{ activeCellDetail.rawValuePreview }}</pre
|
||||
>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ExternalLink, Maximize2, X, ZoomIn, ZoomOut } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { imagePreviewFitScale, imagePreviewTransform, nextImagePreviewScale } from "@/lib/imagePreviewViewer";
|
||||
import { imagePreviewDialogSize, imagePreviewFitScale, imagePreviewTransform, nextImagePreviewScale } from "@/lib/imagePreviewViewer";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
@@ -25,6 +25,8 @@ const imageError = ref(false);
|
||||
const stageRef = ref<HTMLElement>();
|
||||
const naturalWidth = ref(0);
|
||||
const naturalHeight = ref(0);
|
||||
const viewportWidth = ref(typeof window === "undefined" ? 0 : window.innerWidth);
|
||||
const viewportHeight = ref(typeof window === "undefined" ? 0 : window.innerHeight);
|
||||
const dragStart = ref<{ x: number; y: number; offsetX: number; offsetY: number } | null>(null);
|
||||
|
||||
const hostLabel = computed(() => {
|
||||
@@ -38,6 +40,7 @@ const hostLabel = computed(() => {
|
||||
|
||||
const imageTitle = computed(() => props.title || t("grid.imagePreview"));
|
||||
const zoomLabel = computed(() => `${Math.round(scale.value * 100)}%`);
|
||||
const canOpenExternal = computed(() => !props.src.startsWith("data:"));
|
||||
const imageStyle = computed(() => ({
|
||||
width: naturalWidth.value ? `${naturalWidth.value}px` : "auto",
|
||||
height: naturalHeight.value ? `${naturalHeight.value}px` : "auto",
|
||||
@@ -47,6 +50,25 @@ const imageStyle = computed(() => ({
|
||||
offsetY: offsetY.value,
|
||||
})}`,
|
||||
}));
|
||||
const dialogSize = computed(() =>
|
||||
imagePreviewDialogSize({
|
||||
imageWidth: naturalWidth.value,
|
||||
imageHeight: naturalHeight.value,
|
||||
viewportWidth: viewportWidth.value,
|
||||
viewportHeight: viewportHeight.value,
|
||||
}),
|
||||
);
|
||||
const dialogStyle = computed(() => ({
|
||||
width: dialogSize.value ? `${dialogSize.value.width}px` : "min(92vw, 1280px)",
|
||||
height: dialogSize.value ? `${dialogSize.value.height}px` : "min(86vh, 720px)",
|
||||
maxWidth: "92vw",
|
||||
maxHeight: "86vh",
|
||||
}));
|
||||
|
||||
function updateViewportSize() {
|
||||
viewportWidth.value = window.innerWidth;
|
||||
viewportHeight.value = window.innerHeight;
|
||||
}
|
||||
|
||||
function resetViewer() {
|
||||
scale.value = 1;
|
||||
@@ -93,7 +115,7 @@ function onImageLoad(event: Event) {
|
||||
naturalWidth.value = img.naturalWidth;
|
||||
naturalHeight.value = img.naturalHeight;
|
||||
imageLoaded.value = true;
|
||||
fitImage();
|
||||
requestAnimationFrame(fitImage);
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
@@ -136,14 +158,29 @@ async function openExternal() {
|
||||
watch(
|
||||
() => [props.open, props.src] as const,
|
||||
([open]) => {
|
||||
if (open) resetViewer();
|
||||
if (open) {
|
||||
updateViewportSize();
|
||||
resetViewer();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch([viewportWidth, viewportHeight], () => {
|
||||
if (props.open && imageLoaded.value) fitImage();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("resize", updateViewportSize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", updateViewportSize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="(value) => emit('update:open', value)">
|
||||
<DialogContent :show-close-button="false" class="image-preview-dialog h-[min(86vh,920px)] w-[min(92vw,1280px)] max-w-none gap-0 overflow-hidden rounded-xl border-white/10 bg-[#090b0f] p-0 text-white shadow-2xl" @escape-key-down="close">
|
||||
<DialogContent :show-close-button="false" class="image-preview-dialog !flex !max-w-[92vw] flex-col gap-0 overflow-hidden rounded-xl border-white/10 bg-[#090b0f] p-0 text-white shadow-2xl sm:!max-w-[92vw]" :style="dialogStyle" @escape-key-down="close">
|
||||
<div class="flex h-12 shrink-0 items-center gap-3 border-b border-white/10 bg-white/[0.035] px-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<DialogTitle class="truncate text-sm font-semibold text-white">{{ imageTitle }}</DialogTitle>
|
||||
@@ -160,7 +197,7 @@ watch(
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white" :title="t('grid.fitImage')" @click="fitImage">
|
||||
<Maximize2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white" :title="t('grid.openImage')" @click="openExternal">
|
||||
<Button v-if="canOpenExternal" variant="ghost" size="icon" class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white" :title="t('grid.openImage')" @click="openExternal">
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user