2
0
Эх сурвалжийг харах

support media preview if the data is an image (#685)

Signed-off-by: shanghaikid <jiangruiyi@gmail.com>
ryjiang 8 сар өмнө
parent
commit
86b38b3bc2

+ 110 - 0
client/src/components/MediaPreview/MediaPreview.tsx

@@ -0,0 +1,110 @@
+import React, { useState, useEffect } from 'react';
+import icons from '../icons/Icons';
+
+const MediaPreview = (props: { value: string }) => {
+  const { value } = props;
+  const [showImage, setShowImage] = useState(false);
+  const [image, setImage] = useState('');
+  const [showImageStyle, setShowImageStyle] = useState({});
+  const [imageDimensions, setImageDimensions] = useState({
+    width: 0,
+    height: 0,
+  });
+
+  useEffect(() => {
+    if (isImageSource(value)) {
+      setImage(value);
+
+      // Create an Image object to get natural dimensions
+      const img = new Image();
+      img.src = value;
+      img.onload = () => {
+        setImageDimensions({
+          width: img.naturalWidth,
+          height: img.naturalHeight,
+        });
+      };
+    }
+  }, [value]);
+
+  const handleMouseOver = (e: React.MouseEvent) => {
+    // Use dynamic image dimensions instead of fixed values
+    const imageWidth =
+      imageDimensions.width > 200 ? 200 : imageDimensions.width;
+    const imageHeight =
+      imageDimensions.height > 200
+        ? imageDimensions.height * (200 / imageDimensions.width)
+        : imageDimensions.height;
+    const offset = 10; // Small offset to position the image beside the cursor
+
+    console.log('imageHeight', imageHeight);
+
+    // Calculate preliminary position
+    let left = e.clientX + offset - imageWidth / 2;
+    let top = e.clientY - imageHeight - 20;
+
+    console.log(top);
+
+    // Ensure the image stays within viewport boundaries
+    if (left + imageWidth > window.innerWidth) {
+      left = e.clientX - imageWidth - offset; // Move to the left side of the cursor if it exceeds the right boundary
+    }
+    if (left < 0) {
+      left = offset; // Move right if it goes off the left edge
+    }
+    if (top + imageHeight > window.innerHeight) {
+      top = window.innerHeight - imageHeight - offset; // Adjust to stay within the bottom boundary
+    }
+    if (top < 0) {
+      top = offset; // Adjust to stay within the top boundary
+    }
+
+    if (image) {
+      setShowImage(true);
+      setShowImageStyle({
+        position: 'fixed',
+        top: `${top}px`,
+        left: `${left}px`,
+        zIndex: 1000,
+        pointerEvents: 'none',
+      });
+    }
+  };
+
+  const handleMouseOut = () => {
+    setShowImage(false);
+  };
+
+  const isImg = isImageSource(value);
+
+  return (
+    <div style={{ position: 'relative', display: 'inline-block' }}>
+      <div
+        onMouseMove={handleMouseOver}
+        onMouseOut={handleMouseOut}
+        style={{ cursor: 'pointer' }}
+      >
+        {isImg && <icons.img />} {value}
+      </div>
+      {showImage && (
+        <div style={showImageStyle}>
+          <img
+            src={image}
+            alt="preview"
+            style={{ width: 'auto', maxWidth: '200px', borderRadius: '4px' }}
+          />
+        </div>
+      )}
+    </div>
+  );
+};
+
+// Helper function to detect if the value is a URL or Base64-encoded image
+function isImageSource(value: string): boolean {
+  const urlPattern = /\.(jpeg|jpg|gif|png|bmp|webp|svg)$/i;
+  const base64Pattern =
+    /^data:image\/(png|jpeg|jpg|gif|bmp|webp|svg\+xml);base64,/i;
+  return urlPattern.test(value) || base64Pattern.test(value);
+}
+
+export default MediaPreview;

+ 17 - 0
client/src/components/icons/Icons.tsx

@@ -926,6 +926,23 @@ const icons: { [x in IconsType]: (props?: any) => React.ReactElement } = {
       ></path>
     </SvgIcon>
   ),
+  img: (props = {}) => (
+    <svg
+      width="15"
+      height="15"
+      viewBox="0 0 15 15"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg"
+      {...props}
+    >
+      <path
+        d="M2.5 1H12.5C13.3284 1 14 1.67157 14 2.5V12.5C14 13.3284 13.3284 14 12.5 14H2.5C1.67157 14 1 13.3284 1 12.5V2.5C1 1.67157 1.67157 1 2.5 1ZM2.5 2C2.22386 2 2 2.22386 2 2.5V8.3636L3.6818 6.6818C3.76809 6.59551 3.88572 6.54797 4.00774 6.55007C4.12975 6.55216 4.24568 6.60372 4.32895 6.69293L7.87355 10.4901L10.6818 7.6818C10.8575 7.50607 11.1425 7.50607 11.3182 7.6818L13 9.3636V2.5C13 2.22386 12.7761 2 12.5 2H2.5ZM2 12.5V9.6364L3.98887 7.64753L7.5311 11.4421L8.94113 13H2.5C2.22386 13 2 12.7761 2 12.5ZM12.5 13H10.155L8.48336 11.153L11 8.6364L13 10.6364V12.5C13 12.7761 12.7761 13 12.5 13ZM6.64922 5.5C6.64922 5.03013 7.03013 4.64922 7.5 4.64922C7.96987 4.64922 8.35078 5.03013 8.35078 5.5C8.35078 5.96987 7.96987 6.35078 7.5 6.35078C7.03013 6.35078 6.64922 5.96987 6.64922 5.5ZM7.5 3.74922C6.53307 3.74922 5.74922 4.53307 5.74922 5.5C5.74922 6.46693 6.53307 7.25078 7.5 7.25078C8.46693 7.25078 9.25078 6.46693 9.25078 5.5C9.25078 4.53307 8.46693 3.74922 7.5 3.74922Z"
+        fill="currentColor"
+        fill-rule="evenodd"
+        clip-rule="evenodd"
+      ></path>
+    </svg>
+  ),
 };
 
 export default icons;

+ 2 - 1
client/src/components/icons/Types.ts

@@ -57,4 +57,5 @@ export type IconsType =
   | 'link'
   | 'cross'
   | 'day'
-  | 'night';
+  | 'night'
+  | 'img';

+ 3 - 0
client/src/pages/databases/collections/data/CollectionData.tsx

@@ -31,6 +31,7 @@ import StatusIcon, { LoadingType } from '@/components/status/StatusIcon';
 import CustomInput from '@/components/customInput/CustomInput';
 import CustomMultiSelector from '@/components/customSelector/CustomMultiSelector';
 import CollectionColHeader from '../CollectionColHeader';
+import MediaPreview from '@/components/MediaPreview/MediaPreview';
 
 export interface CollectionDataProps {
   collectionName: string;
@@ -511,6 +512,8 @@ const CollectionData = (props: CollectionDataProps) => {
                     case 'bool':
                       const res = JSON.stringify(cellData);
                       return <Typography title={res}>{res}</Typography>;
+                    case 'string':
+                      return <MediaPreview value={cellData} />;
                     default:
                       return cellData;
                   }

+ 16 - 0
client/src/pages/databases/collections/search/Search.tsx

@@ -31,6 +31,7 @@ import {
   buildSearchParams,
   buildSearchCode,
   getColumnWidth,
+  detectItemType,
 } from '@/utils';
 import SearchParams from '../../../search/SearchParams';
 import DataExplorer, { formatMilvusData } from './DataExplorer';
@@ -45,6 +46,7 @@ import { ColDefinitionsType } from '@/components/grid/Types';
 import { CollectionObject, CollectionFullObject } from '@server/types';
 import CodeDialog from '@/pages/dialogs/CodeDialog';
 import CollectionColHeader from '../CollectionColHeader';
+import MediaPreview from '@/components/MediaPreview/MediaPreview';
 
 export interface CollectionDataProps {
   collectionName: string;
@@ -326,6 +328,20 @@ const Search = (props: CollectionDataProps) => {
               headerFormatter: v => {
                 return <CollectionColHeader def={v} collection={collection} />;
               },
+              formatter(_: any, cellData: any) {
+                const itemType = detectItemType(cellData);
+                switch (itemType) {
+                  case 'json':
+                  case 'array':
+                  case 'bool':
+                    const res = JSON.stringify(cellData);
+                    return <Typography title={res}>{res}</Typography>;
+                  case 'string':
+                    return <MediaPreview value={cellData} />;
+                  default:
+                    return cellData;
+                }
+              },
               getStyle: d => {
                 const field = collection.schema.fields.find(
                   f => f.name === key