Skip to content

Commit

Permalink
perf: interactive optimization of gallery view (#1055)
Browse files Browse the repository at this point in the history
* fix: interaction issues with card covers

* perf: optimize infinite scroll of gallery view

* perf: set default value for cover field when creating gallery view

* fix: rendering issue for non-image attachments

* feat: update usage feature limit key
  • Loading branch information
Sky-FE authored Nov 5, 2024
1 parent fe5151f commit 1fdc1ee
Show file tree
Hide file tree
Showing 11 changed files with 338 additions and 139 deletions.
5 changes: 3 additions & 2 deletions apps/nestjs-backend/src/features/record/record.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1223,11 +1223,12 @@ export class RecordService {
(await this.attachmentStorageService.getTableThumbnailUrl(lgThumbnailPath, mimetype));
}
}
const isImage = mimetype.startsWith('image/');
return {
...item,
presignedUrl,
smThumbnailUrl: smThumbnailUrl || presignedUrl,
lgThumbnailUrl: lgThumbnailUrl || presignedUrl,
smThumbnailUrl: isImage ? smThumbnailUrl || presignedUrl : undefined,
lgThumbnailUrl: isImage ? lgThumbnailUrl || presignedUrl : undefined,
};
})
);
Expand Down
18 changes: 18 additions & 0 deletions apps/nestjs-backend/src/features/view/view.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
IKanbanViewOptions,
IFilterSet,
IPluginViewOptions,
IGalleryViewOptions,
} from '@teable/core';
import {
getUniqName,
Expand All @@ -25,6 +26,7 @@ import {
ViewOpBuilder,
viewVoSchema,
ViewType,
FieldType,
} from '@teable/core';
import type { Prisma } from '@teable/db-main-prisma';
import { PrismaService } from '@teable/db-main-prisma';
Expand Down Expand Up @@ -150,6 +152,22 @@ export class ViewService implements IReadonlyAdapterService {
...columnMeta,
[primaryField.id]: { ...primaryFieldColumnMeta, visible: true },
};

// set default cover field id for gallery view
if (innerViewRo.type === ViewType.Gallery) {
const fields = await this.prismaService.txClient().field.findMany({
where: { tableId, deletedTime: null },
select: { id: true, type: true },
});
const galleryOptions = (innerViewRo.options ?? {}) as IGalleryViewOptions;
const coverFieldId =
galleryOptions.coverFieldId ??
fields.find((field) => field.type === FieldType.Attachment)?.id;
innerViewRo.options = {
...galleryOptions,
coverFieldId,
};
}
}
return innerViewRo;
}
Expand Down
27 changes: 27 additions & 0 deletions apps/nestjs-backend/test/view.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,33 @@ describe('OpenAPI ViewController (e2e)', () => {
]);
});

it('/api/table/{tableId}/view (POST) with gallery view', async () => {
const viewRo: IViewRo = {
name: 'New gallery view',
description: 'the new gallery view',
type: ViewType.Gallery,
};

const fieldVo = await createField(table.id, {
name: 'Attachment',
type: FieldType.Attachment,
});
await createView(table.id, viewRo);

const result = await getViews(table.id);
expect(result).toMatchObject([
...defaultViews,
{
name: 'New gallery view',
description: 'the new gallery view',
type: ViewType.Gallery,
options: {
coverFieldId: fieldVo.id,
},
},
]);
});

it('should update view simple properties', async () => {
const viewRo: IViewRo = {
name: 'New view',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,42 +7,31 @@ import {
DragOverlay,
closestCenter,
} from '@dnd-kit/core';
import { SortableContext, arrayMove, rectSortingStrategy } from '@dnd-kit/sortable';
import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
import { useVirtualizer } from '@tanstack/react-virtual';
import { FieldKeyType } from '@teable/core';
import { useRecords, useRowCount, useTableId, useViewId } from '@teable/sdk/hooks';
import { Record } from '@teable/sdk/model';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRowCount, useTableId, useViewId } from '@teable/sdk/hooks';
import { Record as RecordModel } from '@teable/sdk/model';
import { cn } from '@teable/ui-lib/shadcn';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Card } from './components/Card';
import { SortableItem } from './components/SortableItem';
import { useGallery } from './hooks';
import { useGallery, useCacheRecords } from './hooks';
import { calculateColumns, getCardHeight } from './utils';

const DEFAULT_TAKE = 200;

export const GalleryViewBase = () => {
const { recordQuery, displayFields, coverField, isFieldNameHidden } = useGallery();
const { recordQuery, displayFields, coverField, isFieldNameHidden, permission } = useGallery();
const tableId = useTableId() as string;
const viewId = useViewId() as string;
const rowCount = useRowCount() ?? 0;
const { cardDraggable } = permission;

const [skip, setSkip] = useState(0);
const [activeId, setActiveId] = useState<string | null>(null);
const parentRef = useRef<HTMLDivElement>(null);
const skipIndexRef = useRef(skip);
const [columnsPerRow, setColumnsPerRow] = useState(4);

const query = useMemo(() => {
return {
...recordQuery,
skip,
take: DEFAULT_TAKE,
};
}, [recordQuery, skip]);

const { records } = useRecords(query);

const [cards, setCards] = useState<Record[]>(records);
const { skip, recordIds, loadedRecordMap, updateSkipIndex, updateRecordOrder } =
useCacheRecords(recordQuery);

const virtualizer = useVirtualizer({
count: Math.ceil(rowCount / columnsPerRow),
Expand Down Expand Up @@ -85,58 +74,48 @@ export const GalleryViewBase = () => {
};
}, [updateGridColumns]);

useEffect(() => {
if (!records.length) return;

setCards((prev) => {
const merged = [...prev];
records.forEach((record, index) => {
merged[skipIndexRef.current + index] = record;
});
return merged;
});
}, [records]);

useEffect(() => {
if (!virtualizer.range) return;
const { endIndex } = virtualizer.range;
const actualIndex = endIndex * columnsPerRow;
const newSkip = Math.floor(actualIndex / DEFAULT_TAKE) * DEFAULT_TAKE;

if (newSkip >= rowCount) return;

skipIndexRef.current = newSkip;
setSkip(newSkip);
}, [columnsPerRow, rowCount, virtualizer.range]);
const { startIndex } = virtualizer.range;
const actualStartIndex = startIndex * columnsPerRow;
updateSkipIndex(actualStartIndex, rowCount);
}, [columnsPerRow, rowCount, virtualizer.range, updateSkipIndex]);

const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};

const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
const activeId = active.id;
const overId = over?.id;

if (active.id !== over?.id) {
const oldIndex = cards.findIndex((item) => item.id === active.id);
const newIndex = cards.findIndex((item) => item.id === over?.id);
const newCards = arrayMove(cards, oldIndex, newIndex);

setCards(newCards);

if (oldIndex != null && newIndex != null && oldIndex !== newIndex) {
Record.updateRecord(tableId, active.id as string, {
fieldKeyType: FieldKeyType.Id,
record: { fields: {} },
order: {
viewId,
anchorId: over?.id as string,
position: oldIndex > newIndex ? 'before' : 'after',
},
});
}
}
if (!activeId || !overId || activeId === overId) return;

const oldIndex = recordIds.findIndex((id) => id === activeId);
const newIndex = recordIds.findIndex((id) => id === overId);

if (oldIndex == null || newIndex == null || oldIndex === newIndex) return;

const actualOldIndex = oldIndex + skip;
const actualNewIndex = newIndex + skip;

updateRecordOrder(actualOldIndex, actualNewIndex);

RecordModel.updateRecord(tableId, activeId as string, {
fieldKeyType: FieldKeyType.Id,
record: { fields: {} },
order: {
viewId,
anchorId: overId as string,
position: actualOldIndex > actualNewIndex ? 'before' : 'after',
},
});
};

const activeIndex = activeId ? recordIds.findIndex((id) => id === activeId) : null;
const activeRecord = activeIndex != null ? loadedRecordMap[activeIndex + skip] : null;

return (
<DndContext
sensors={sensors}
Expand All @@ -145,7 +124,7 @@ export const GalleryViewBase = () => {
onDragEnd={handleDragEnd}
>
<div ref={parentRef} className="size-full overflow-auto p-4">
<SortableContext items={cards} strategy={rectSortingStrategy}>
<SortableContext items={recordIds} strategy={rectSortingStrategy} disabled={!cardDraggable}>
<div
className="relative w-full"
style={{
Expand All @@ -162,15 +141,20 @@ export const GalleryViewBase = () => {
}}
>
{Array.from({ length: columnsPerRow }).map((_, i) => {
const card = cards[virtualRow.index * columnsPerRow + i];
const actualIndex = virtualRow.index * columnsPerRow + i;
const card = loadedRecordMap[actualIndex];

return card ? (
<SortableItem key={card.id} id={card.id}>
<Card card={card} />
</SortableItem>
) : (
<div
key={`placeholder-${virtualRow.index}-${i}`}
className="flex-1 bg-background"
className={cn(
'flex-1 rounded-md',
actualIndex >= rowCount ? 'bg-transparent' : 'bg-gray-100 dark:bg-gray-800'
)}
/>
);
})}
Expand All @@ -179,9 +163,7 @@ export const GalleryViewBase = () => {
</div>
</SortableContext>
</div>
<DragOverlay>
{activeId ? <Card card={cards.find((c) => c?.id === activeId)!} /> : null}
</DragOverlay>
<DragOverlay>{activeRecord ? <Card card={activeRecord} /> : null}</DragOverlay>
</DndContext>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { ArrowDown, ArrowUp, Image, Maximize2, Trash } from '@teable/icons';
import type { IRecordInsertOrderRo } from '@teable/openapi';
import { createRecords, deleteRecord } from '@teable/openapi';
import { CellValue, getFileCover } from '@teable/sdk/components';
import { useAttachmentPreviewI18Map } from '@teable/sdk/components/hooks';
import { useFieldStaticGetter, useTableId, useViewId } from '@teable/sdk/hooks';
import type { Record } from '@teable/sdk/model';
import { FilePreviewItem, FilePreviewProvider } from '@teable/ui-lib/base';
import {
Carousel,
CarouselContent,
Expand Down Expand Up @@ -34,6 +36,7 @@ export const Card = (props: IKanbanCardProps) => {
const tableId = useTableId();
const viewId = useViewId();
const getFieldStatic = useFieldStaticGetter();
const i18nMap = useAttachmentPreviewI18Map();
const { t } = useTranslation(tableConfig.i18nNamespaces);
const {
coverField,
Expand Down Expand Up @@ -95,38 +98,61 @@ export const Card = (props: IKanbanCardProps) => {
{coverFieldId && (
<Fragment>
{coverCellValue?.length ? (
<Carousel
opts={{
watchDrag: false,
watchResize: false,
watchSlides: false,
}}
className="border-b"
>
<CarouselContent className="ml-0">
{coverCellValue.map(({ id, mimetype, presignedUrl, lgThumbnailUrl }) => {
const url = lgThumbnailUrl ?? getFileCover(mimetype, presignedUrl);
return (
<CarouselItem
key={id}
style={{ height: CARD_COVER_HEIGHT }}
className="relative size-full pl-0"
>
<img
src={url}
alt="card cover"
className="size-full"
style={{
objectFit: isCoverFit ? 'contain' : 'cover',
}}
/>
</CarouselItem>
);
})}
</CarouselContent>
<CarouselPrevious className="left-1" onClick={(e) => e.stopPropagation()} />
<CarouselNext className="right-1" onClick={(e) => e.stopPropagation()} />
</Carousel>
<FilePreviewProvider i18nMap={i18nMap}>
<Carousel
opts={{
watchDrag: false,
watchResize: false,
watchSlides: false,
}}
className="border-b"
>
<CarouselContent className="ml-0">
{coverCellValue.map(
({ id, name, size, mimetype, presignedUrl, lgThumbnailUrl }) => {
const url = lgThumbnailUrl ?? getFileCover(mimetype, presignedUrl);
return (
<CarouselItem
key={id}
style={{ height: CARD_COVER_HEIGHT }}
className="relative size-full pl-0"
>
<FilePreviewItem
key={id}
className="size-full cursor-pointer"
src={presignedUrl || ''}
name={name}
mimetype={mimetype}
size={size}
>
<img
src={url}
alt="card cover"
className="size-full"
style={{
objectFit: isCoverFit ? 'contain' : 'cover',
}}
/>
</FilePreviewItem>
</CarouselItem>
);
}
)}
</CarouselContent>
{coverCellValue?.length > 1 && (
<Fragment>
<CarouselPrevious
className="left-1 size-7"
onClick={(e) => e.stopPropagation()}
/>
<CarouselNext
className="right-1 size-7"
onClick={(e) => e.stopPropagation()}
/>
</Fragment>
)}
</Carousel>
</FilePreviewProvider>
) : (
<div
style={{ height: CARD_COVER_HEIGHT }}
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './useGallery';
export * from './useCacheRecords';
Loading

0 comments on commit 1fdc1ee

Please sign in to comment.