From 0529c32fb74b5416eef5cb3f2a0b2114f8446d92 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 14:52:43 +0000 Subject: [PATCH 1/2] feat(app): add gallery presentation mode for shared-screen review Introduce a dual-view Present toggle on gallery views: compact gallery stays unchanged, while Present opens a separate full-viewport slide experience with large typography and keyboard navigation. Co-authored-by: MerpGoaterman --- .../view/component/gallery/GalleryView.tsx | 33 ++-- .../component/gallery/toolbar/Toolbar.tsx | 2 + .../app/blocks/view/gallery/GalleryView.tsx | 29 +-- .../view/gallery/GalleryViewContent.tsx | 15 ++ .../components/GalleryPresentationView.tsx | 180 ++++++++++++++++++ .../gallery/components/PresentationSlide.tsx | 114 +++++++++++ .../blocks/view/gallery/components/index.ts | 2 + .../context/PresentationModeContext.ts | 16 ++ .../context/PresentationModeProvider.tsx | 11 ++ .../app/blocks/view/gallery/context/index.ts | 2 + .../app/blocks/view/gallery/utils/index.ts | 1 + .../view/gallery/utils/presentation.spec.ts | 90 +++++++++ .../blocks/view/gallery/utils/presentation.ts | 49 +++++ .../components/GalleryViewOperators.tsx | 2 + .../tool-bar/components/PresentToggle.tsx | 27 +++ .../blocks/view/tool-bar/components/index.ts | 1 + .../common-i18n/src/locales/ar/table.json | 14 ++ .../common-i18n/src/locales/de/table.json | 14 ++ .../common-i18n/src/locales/en/table.json | 14 ++ .../common-i18n/src/locales/es/table.json | 14 ++ .../common-i18n/src/locales/fr/table.json | 14 ++ .../common-i18n/src/locales/he/table.json | 14 ++ .../common-i18n/src/locales/it/table.json | 14 ++ .../common-i18n/src/locales/ja/table.json | 14 ++ .../common-i18n/src/locales/ru/table.json | 14 ++ .../common-i18n/src/locales/tr/table.json | 14 ++ .../common-i18n/src/locales/uk/table.json | 14 ++ .../common-i18n/src/locales/zh/table.json | 14 ++ 28 files changed, 712 insertions(+), 30 deletions(-) create mode 100644 apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryViewContent.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/view/gallery/components/GalleryPresentationView.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/view/gallery/components/PresentationSlide.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/view/gallery/context/PresentationModeContext.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/view/gallery/context/PresentationModeProvider.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/view/gallery/utils/presentation.spec.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/view/gallery/utils/presentation.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/PresentToggle.tsx diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/GalleryView.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/GalleryView.tsx index 9a1112aadd..b7c5c5bafd 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/GalleryView.tsx +++ b/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/GalleryView.tsx @@ -1,17 +1,18 @@ import { RecordProvider, RowCountProvider, ShareViewContext } from '@teable/sdk/context'; import { SearchProvider } from '@teable/sdk/context/query'; -import { useIsHydrated } from '@teable/sdk/hooks'; import { cn } from '@teable/ui-lib/shadcn'; import { useRouter } from 'next/router'; import { useContext } from 'react'; -import { GalleryProvider } from '@/features/app/blocks/view/gallery/context'; -import { GalleryViewBase } from '@/features/app/blocks/view/gallery/GalleryViewBase'; +import { + GalleryProvider, + PresentationModeProvider, +} from '@/features/app/blocks/view/gallery/context'; +import { GalleryViewContent } from '@/features/app/blocks/view/gallery/GalleryViewContent'; import { ShareViewHeader } from '../../ShareSignInButton'; import { GalleryToolbar } from './toolbar'; export const GalleryView = () => { const { view } = useContext(ShareViewContext); - const isHydrated = useIsHydrated(); const { query: { hideToolBar, embed }, } = useRouter(); @@ -19,18 +20,18 @@ export const GalleryView = () => {
{!embed && }
- - - - {!hideToolBar && } - -
- {isHydrated && } -
-
-
-
-
+ + + + + {!hideToolBar && } + + + + + + +
); diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/toolbar/Toolbar.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/toolbar/Toolbar.tsx index fa1486ad34..380a377523 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/toolbar/Toolbar.tsx +++ b/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/toolbar/Toolbar.tsx @@ -4,6 +4,7 @@ import { useView } from '@teable/sdk/hooks/use-view'; import { cn } from '@teable/ui-lib/shadcn'; import { useToolbarChange } from '@/features/app/blocks/view/hooks/useToolbarChange'; import { SearchButton } from '@/features/app/blocks/view/search/SearchButton'; +import { PresentToggle } from '@/features/app/blocks/view/tool-bar/components/PresentToggle'; import { ToolBarButton } from '@/features/app/blocks/view/tool-bar/ToolBarButton'; import { Sort } from '../../grid/toolbar/Sort'; import { ShareViewFilter } from '../../share-view-filter'; @@ -50,6 +51,7 @@ export const GalleryToolbar: React.FC<{ disabled?: boolean }> = (props) => { )} +
diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryView.tsx b/apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryView.tsx index cef79c5732..03aa0731c1 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryView.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryView.tsx @@ -1,24 +1,25 @@ import { RecordProvider, RowCountProvider } from '@teable/sdk/context'; import { SearchProvider } from '@teable/sdk/context/query'; -import { useIsHydrated, usePersonalView } from '@teable/sdk/hooks'; +import { usePersonalView } from '@teable/sdk/hooks'; import { GalleryToolBar } from '../tool-bar/GalleryToolBar'; -import { GalleryProvider } from './context'; -import { GalleryViewBase } from './GalleryViewBase'; +import { GalleryProvider, PresentationModeProvider } from './context'; +import { GalleryViewContent } from './GalleryViewContent'; export const GalleryView = () => { - const isHydrated = useIsHydrated(); const { personalViewCommonQuery } = usePersonalView(); return ( - - - - - -
{isHydrated && }
-
-
-
-
+ + + + + + + + + + + + ); }; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryViewContent.tsx b/apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryViewContent.tsx new file mode 100644 index 0000000000..975bd27798 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryViewContent.tsx @@ -0,0 +1,15 @@ +import { useIsHydrated } from '@teable/sdk/hooks'; +import { GalleryPresentationView } from './components/GalleryPresentationView'; +import { usePresentationMode } from './context/PresentationModeContext'; +import { GalleryViewBase } from './GalleryViewBase'; + +export const GalleryViewContent = () => { + const isHydrated = useIsHydrated(); + const { isPresenting } = usePresentationMode(); + + return ( +
+ {isHydrated && (isPresenting ? : )} +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/components/GalleryPresentationView.tsx b/apps/nextjs-app/src/features/app/blocks/view/gallery/components/GalleryPresentationView.tsx new file mode 100644 index 0000000000..b1aeaaac15 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/components/GalleryPresentationView.tsx @@ -0,0 +1,180 @@ +import { ChevronLeft, ChevronRight, X } from '@teable/icons'; +import { useRecords, useRowCount } from '@teable/sdk/hooks'; +import { Button, cn } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { tableConfig } from '@/features/i18n/table.config'; +import { usePresentationMode } from '../context/PresentationModeContext'; +import { useGallery } from '../hooks'; +import { + clampIndex, + getRecordWindow, + resolvePresentationKey, + shouldIgnorePresentationHotkey, + stepIndex, +} from '../utils/presentation'; +import { PresentationSlide } from './PresentationSlide'; + +export const GalleryPresentationView = () => { + const { t } = useTranslation(tableConfig.i18nNamespaces); + const { setPresenting } = usePresentationMode(); + const { recordQuery, displayFields, coverField, isCoverFit, isFieldNameHidden, primaryField } = + useGallery(); + const rowCount = useRowCount() ?? 0; + const [index, setIndex] = useState(0); + const rootRef = useRef(null); + + const { skip, take, offset } = getRecordWindow(index); + const windowQuery = useMemo( + () => ({ + ...recordQuery, + skip, + take, + }), + [recordQuery, skip, take] + ); + const { records } = useRecords(windowQuery); + const record = records[offset]; + + useEffect(() => { + setIndex((current) => clampIndex(current, rowCount)); + }, [rowCount]); + + const goTo = useCallback( + (delta: number) => { + setIndex((current) => stepIndex(current, rowCount, delta)); + }, + [rowCount] + ); + + const exit = useCallback(() => { + setPresenting(false); + }, [setPresenting]); + + useEffect(() => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + rootRef.current?.focus(); + return () => { + document.body.style.overflow = previousOverflow; + }; + }, []); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (shouldIgnorePresentationHotkey(event.target)) return; + const action = resolvePresentationKey(event); + if (!action) return; + event.preventDefault(); + if (action === 'exit') { + exit(); + return; + } + goTo(action === 'next' ? 1 : -1); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [exit, goTo]); + + const atStart = index <= 0; + const atEnd = rowCount === 0 || index >= rowCount - 1; + const counter = + rowCount === 0 + ? t('table:gallery.presentation.empty') + : t('table:gallery.presentation.counter', { current: index + 1, total: rowCount }); + + return createPortal( +
+
+

{counter}

+

+ {t('table:gallery.presentation.keyboardHint')} +

+ +
+ +
+ + +
+ {rowCount === 0 ? ( +

{t('table:gallery.presentation.empty')}

+ ) : ( + + )} +
+ + +
+ +
+ + +
+
, + document.body + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/components/PresentationSlide.tsx b/apps/nextjs-app/src/features/app/blocks/view/gallery/components/PresentationSlide.tsx new file mode 100644 index 0000000000..98a93df10b --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/components/PresentationSlide.tsx @@ -0,0 +1,114 @@ +import type { IAttachmentCellValue } from '@teable/core'; +import { Image } from '@teable/icons'; +import { useTheme } from '@teable/next-themes'; +import { CellValue, getFileCover, isSystemFileIcon } from '@teable/sdk/components'; +import { useFieldStaticGetter } from '@teable/sdk/hooks'; +import type { IFieldInstance, Record } from '@teable/sdk/model'; +import { cn } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useMemo } from 'react'; +import { tableConfig } from '@/features/i18n/table.config'; + +interface IPresentationSlideProps { + card?: Record; + primaryField: IFieldInstance | null; + displayFields: IFieldInstance[]; + coverField?: IFieldInstance; + isCoverFit?: boolean; + isFieldNameHidden?: boolean; +} + +const PresentationCover = ({ + value, + isCoverFit, +}: { + value?: IAttachmentCellValue; + isCoverFit?: boolean; +}) => { + const { resolvedTheme } = useTheme(); + const first = value?.[0]; + + if (!first) { + return ( +
+ +
+ ); + } + + const isSystemFile = isSystemFileIcon(first.mimetype); + const url = + first.lgThumbnailUrl ?? + getFileCover(first.mimetype, first.presignedUrl, resolvedTheme as 'light' | 'dark'); + + return ( +
+ +
+ ); +}; + +export const PresentationSlide = (props: IPresentationSlideProps) => { + const { card, primaryField, displayFields, coverField, isCoverFit, isFieldNameHidden } = props; + const getFieldStatic = useFieldStaticGetter(); + const { t } = useTranslation(tableConfig.i18nNamespaces); + + const titleComponent = useMemo(() => { + if (primaryField == null || card == null) { + return {t('untitled')}; + } + const value = card.getCellValue(primaryField.id); + if (value == null) { + return {t('untitled')}; + } + return ; + }, [card, primaryField, t]); + + const coverCellValue = coverField + ? (card?.getCellValue(coverField.id) as IAttachmentCellValue | undefined) + : undefined; + + return ( +
+ {coverField && } +
+

+ {titleComponent} +

+
+ {displayFields.map((field) => { + const { id: fieldId, name, type, isLookup, isConditionalLookup, aiConfig } = field; + const { Icon } = getFieldStatic(type, { + isLookup, + isConditionalLookup, + hasAiConfig: Boolean(aiConfig), + deniedReadRecord: !field.canReadFieldRecord, + }); + const cellValue = card?.getCellValue(fieldId); + + return ( +
+ {!isFieldNameHidden && ( +
+ + {name} +
+ )} + {cellValue != null ? ( + + ) : ( +
-
+ )} +
+ ); + })} +
+
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/components/index.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/components/index.ts index ece438c45f..910fd63036 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/gallery/components/index.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/components/index.ts @@ -1,2 +1,4 @@ export * from './Card'; export * from './CardCarousel'; +export * from './GalleryPresentationView'; +export * from './PresentationSlide'; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/context/PresentationModeContext.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/context/PresentationModeContext.ts new file mode 100644 index 0000000000..3874ed3a9a --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/context/PresentationModeContext.ts @@ -0,0 +1,16 @@ +import { createContext, useContext } from 'react'; + +export interface IPresentationModeContext { + isPresenting: boolean; + setPresenting: (value: boolean) => void; +} + +export const PresentationModeContext = createContext(null); + +export const usePresentationMode = () => { + const context = useContext(PresentationModeContext); + if (!context) { + throw new Error('usePresentationMode must be used within PresentationModeProvider'); + } + return context; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/context/PresentationModeProvider.tsx b/apps/nextjs-app/src/features/app/blocks/view/gallery/context/PresentationModeProvider.tsx new file mode 100644 index 0000000000..c9d66abb81 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/context/PresentationModeProvider.tsx @@ -0,0 +1,11 @@ +import { useMemo, useState, type ReactNode } from 'react'; +import { PresentationModeContext } from './PresentationModeContext'; + +export const PresentationModeProvider = ({ children }: { children: ReactNode }) => { + const [isPresenting, setPresenting] = useState(false); + const value = useMemo(() => ({ isPresenting, setPresenting }), [isPresenting]); + + return ( + {children} + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/context/index.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/context/index.ts index 803ceb3b56..b9fc345224 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/gallery/context/index.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/context/index.ts @@ -1,2 +1,4 @@ export * from './GalleryContext'; export * from './GalleryProvider'; +export * from './PresentationModeContext'; +export * from './PresentationModeProvider'; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/index.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/index.ts index 61e89e45ec..b4d8a05ac9 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/index.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/index.ts @@ -1,2 +1,3 @@ export * from './card'; export * from './columns'; +export * from './presentation'; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/presentation.spec.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/presentation.spec.ts new file mode 100644 index 0000000000..4f5669eac0 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/presentation.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { + clampIndex, + getRecordWindow, + resolvePresentationKey, + shouldIgnorePresentationHotkey, + stepIndex, +} from './presentation'; + +describe('clampIndex', () => { + it('returns 0 when the set is empty', () => { + expect(clampIndex(3, 0)).toBe(0); + expect(clampIndex(-1, 0)).toBe(0); + }); + + it('clamps to the last record in the filtered set', () => { + expect(clampIndex(9, 4)).toBe(3); + }); + + it('clamps negative indexes to 0', () => { + expect(clampIndex(-2, 4)).toBe(0); + }); + + it('keeps an in-range index', () => { + expect(clampIndex(2, 4)).toBe(2); + }); +}); + +describe('stepIndex', () => { + it('moves forward and backward inside the current filtered set', () => { + expect(stepIndex(1, 5, 1)).toBe(2); + expect(stepIndex(1, 5, -1)).toBe(0); + }); + + it('does not wrap at either end', () => { + expect(stepIndex(0, 5, -1)).toBe(0); + expect(stepIndex(4, 5, 1)).toBe(4); + }); +}); + +describe('getRecordWindow', () => { + it('loads the first three records when presenting the first slide', () => { + expect(getRecordWindow(0)).toEqual({ skip: 0, take: 3, offset: 0 }); + }); + + it('centers the window on the current slide so prev and next stay available', () => { + expect(getRecordWindow(4)).toEqual({ skip: 3, take: 3, offset: 1 }); + }); +}); + +describe('resolvePresentationKey', () => { + it('maps arrow and vim keys to next and previous', () => { + expect(resolvePresentationKey({ key: 'ArrowRight' })).toBe('next'); + expect(resolvePresentationKey({ key: 'j' })).toBe('next'); + expect(resolvePresentationKey({ key: 'J' })).toBe('next'); + expect(resolvePresentationKey({ key: 'ArrowLeft' })).toBe('prev'); + expect(resolvePresentationKey({ key: 'k' })).toBe('prev'); + expect(resolvePresentationKey({ key: 'K' })).toBe('prev'); + }); + + it('maps Escape to exit', () => { + expect(resolvePresentationKey({ key: 'Escape' })).toBe('exit'); + }); + + it('ignores unrelated keys', () => { + expect(resolvePresentationKey({ key: 'Enter' })).toBeNull(); + expect(resolvePresentationKey({ key: ' ' })).toBeNull(); + }); +}); + +describe('shouldIgnorePresentationHotkey', () => { + it('ignores typing in form fields and contenteditable', () => { + const input = document.createElement('input'); + const textarea = document.createElement('textarea'); + const select = document.createElement('select'); + const editable = document.createElement('div'); + editable.contentEditable = 'true'; + + expect(shouldIgnorePresentationHotkey(input)).toBe(true); + expect(shouldIgnorePresentationHotkey(textarea)).toBe(true); + expect(shouldIgnorePresentationHotkey(select)).toBe(true); + expect(shouldIgnorePresentationHotkey(editable)).toBe(true); + }); + + it('handles keys from the slide chrome', () => { + const button = document.createElement('button'); + expect(shouldIgnorePresentationHotkey(button)).toBe(false); + expect(shouldIgnorePresentationHotkey(null)).toBe(false); + }); +}); diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/presentation.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/presentation.ts new file mode 100644 index 0000000000..ed3c206a37 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/presentation.ts @@ -0,0 +1,49 @@ +export type PresentationAction = 'next' | 'prev' | 'exit'; + +export interface IPresentationRecordWindow { + skip: number; + take: number; + offset: number; +} + +export const clampIndex = (index: number, count: number): number => { + if (count <= 0) return 0; + return Math.min(Math.max(index, 0), count - 1); +}; + +export const stepIndex = (index: number, count: number, delta: number): number => { + return clampIndex(index + delta, count); +}; + +export const getRecordWindow = (index: number): IPresentationRecordWindow => { + const skip = Math.max(0, index - 1); + return { + skip, + take: 3, + offset: index === 0 ? 0 : 1, + }; +}; + +export const resolvePresentationKey = (event: { key: string }): PresentationAction | null => { + switch (event.key) { + case 'ArrowRight': + case 'j': + case 'J': + return 'next'; + case 'ArrowLeft': + case 'k': + case 'K': + return 'prev'; + case 'Escape': + return 'exit'; + default: + return null; + } +}; + +export const shouldIgnorePresentationHotkey = (target: EventTarget | null): boolean => { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + return target.isContentEditable; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GalleryViewOperators.tsx b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GalleryViewOperators.tsx index 1878191dcf..596a309098 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GalleryViewOperators.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GalleryViewOperators.tsx @@ -9,6 +9,7 @@ import { tableConfig } from '@/features/i18n/table.config'; import { useToolbarChange } from '../../hooks/useToolbarChange'; import { ToolBarButton } from '../ToolBarButton'; import { CoverFieldSelect } from './CoverFieldSelect'; +import { PresentToggle } from './PresentToggle'; import { ScrollableToolbarGroup } from './ScrollableToolbarGroup'; export const GalleryViewOperators: React.FC<{ disabled?: boolean }> = (props) => { @@ -135,6 +136,7 @@ export const GalleryViewOperators: React.FC<{ disabled?: boolean }> = (props) => )} + ); }; diff --git a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/PresentToggle.tsx b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/PresentToggle.tsx new file mode 100644 index 0000000000..bb72d92d80 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/PresentToggle.tsx @@ -0,0 +1,27 @@ +import { Maximize2, Minimize2 } from '@teable/icons'; +import { useTranslation } from 'next-i18next'; +import { tableConfig } from '@/features/i18n/table.config'; +import { usePresentationMode } from '../../gallery/context/PresentationModeContext'; +import { ToolBarButton } from '../ToolBarButton'; + +export const PresentToggle = () => { + const { isPresenting, setPresenting } = usePresentationMode(); + const { t } = useTranslation(tableConfig.i18nNamespaces); + + return ( + setPresenting(!isPresenting)} + textClassName="@2xl/toolbar:inline" + > + {isPresenting ? ( + + ) : ( + + )} + + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/index.ts b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/index.ts index 1b3a9d2e35..5a5df58c40 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/index.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/index.ts @@ -3,4 +3,5 @@ export * from './KanbanViewOperators'; export * from './GalleryViewOperators'; export * from './CalendarViewOperators'; export * from './PersonalViewSwitch'; +export * from './PresentToggle'; export * from './ToolBarAddRecordButton'; diff --git a/packages/common-i18n/src/locales/ar/table.json b/packages/common-i18n/src/locales/ar/table.json index 727fac5867..9132b87a81 100644 --- a/packages/common-i18n/src/locales/ar/table.json +++ b/packages/common-i18n/src/locales/ar/table.json @@ -1082,6 +1082,20 @@ "duplicateCard": "تكرار البطاقة" } }, + "gallery": { + "toolbar": { + "present": "عرض تقديمي", + "exitPresent": "إنهاء العرض" + }, + "presentation": { + "empty": "لا توجد سجلات للعرض", + "counter": "{{current}} من {{total}}", + "previous": "السابق", + "next": "التالي", + "exit": "إنهاء العرض", + "keyboardHint": "← → أو J K للتنقل · Esc للخروج" + } + }, "calendar": { "toolbar": { "config": "إعداد التقويم", diff --git a/packages/common-i18n/src/locales/de/table.json b/packages/common-i18n/src/locales/de/table.json index 6497fc6bd2..c186e489dd 100644 --- a/packages/common-i18n/src/locales/de/table.json +++ b/packages/common-i18n/src/locales/de/table.json @@ -1062,6 +1062,20 @@ "duplicateCard": "Karte duplizieren" } }, + "gallery": { + "toolbar": { + "present": "Präsentieren", + "exitPresent": "Präsentation beenden" + }, + "presentation": { + "empty": "Keine Datensätze zum Präsentieren", + "counter": "{{current}} von {{total}}", + "previous": "Zurück", + "next": "Weiter", + "exit": "Präsentation beenden", + "keyboardHint": "← → oder J K zum Navigieren · Esc zum Beenden" + } + }, "calendar": { "toolbar": { "config": "Kalendar Konfiguration", diff --git a/packages/common-i18n/src/locales/en/table.json b/packages/common-i18n/src/locales/en/table.json index f35f6fdbbb..19cbf4539f 100644 --- a/packages/common-i18n/src/locales/en/table.json +++ b/packages/common-i18n/src/locales/en/table.json @@ -1062,6 +1062,20 @@ "duplicateCard": "Duplicate card" } }, + "gallery": { + "toolbar": { + "present": "Present", + "exitPresent": "Exit Present" + }, + "presentation": { + "empty": "No records to present", + "counter": "{{current}} of {{total}}", + "previous": "Previous", + "next": "Next", + "exit": "Exit Present", + "keyboardHint": "← → or J K to navigate · Esc to exit" + } + }, "calendar": { "toolbar": { "config": "Calendar config", diff --git a/packages/common-i18n/src/locales/es/table.json b/packages/common-i18n/src/locales/es/table.json index 68aaf07571..56f816597f 100644 --- a/packages/common-i18n/src/locales/es/table.json +++ b/packages/common-i18n/src/locales/es/table.json @@ -1067,6 +1067,20 @@ "duplicateCard": "Duplicar tarjeta" } }, + "gallery": { + "toolbar": { + "present": "Presentar", + "exitPresent": "Salir de la presentación" + }, + "presentation": { + "empty": "No hay registros para presentar", + "counter": "{{current}} de {{total}}", + "previous": "Anterior", + "next": "Siguiente", + "exit": "Salir de la presentación", + "keyboardHint": "← → o J K para navegar · Esc para salir" + } + }, "calendar": { "toolbar": { "config": "Configuración de Calendario", diff --git a/packages/common-i18n/src/locales/fr/table.json b/packages/common-i18n/src/locales/fr/table.json index 73ce4d60e4..e9fdc6ba74 100644 --- a/packages/common-i18n/src/locales/fr/table.json +++ b/packages/common-i18n/src/locales/fr/table.json @@ -1067,6 +1067,20 @@ "duplicateCard": "Dupliquer la carte" } }, + "gallery": { + "toolbar": { + "present": "Présenter", + "exitPresent": "Quitter la présentation" + }, + "presentation": { + "empty": "Aucun enregistrement à présenter", + "counter": "{{current}} sur {{total}}", + "previous": "Précédent", + "next": "Suivant", + "exit": "Quitter la présentation", + "keyboardHint": "← → ou J K pour naviguer · Échap pour quitter" + } + }, "calendar": { "toolbar": { "config": "Configuration du calendrier", diff --git a/packages/common-i18n/src/locales/he/table.json b/packages/common-i18n/src/locales/he/table.json index dcd52437d8..07bb972a18 100644 --- a/packages/common-i18n/src/locales/he/table.json +++ b/packages/common-i18n/src/locales/he/table.json @@ -1067,6 +1067,20 @@ "duplicateCard": "שכפול הכרטיס" } }, + "gallery": { + "toolbar": { + "present": "הצגה", + "exitPresent": "יציאה מההצגה" + }, + "presentation": { + "empty": "אין רשומות להצגה", + "counter": "{{current}} מתוך {{total}}", + "previous": "הקודם", + "next": "הבא", + "exit": "יציאה מההצגה", + "keyboardHint": "← → או J K לניווט · Esc ליציאה" + } + }, "calendar": { "toolbar": { "config": "הגדרות לוח השנה", diff --git a/packages/common-i18n/src/locales/it/table.json b/packages/common-i18n/src/locales/it/table.json index 8b9a93a95c..82b4371843 100644 --- a/packages/common-i18n/src/locales/it/table.json +++ b/packages/common-i18n/src/locales/it/table.json @@ -1067,6 +1067,20 @@ "duplicateCard": "Duplica carta" } }, + "gallery": { + "toolbar": { + "present": "Presenta", + "exitPresent": "Esci dalla presentazione" + }, + "presentation": { + "empty": "Nessun record da presentare", + "counter": "{{current}} di {{total}}", + "previous": "Precedente", + "next": "Successivo", + "exit": "Esci dalla presentazione", + "keyboardHint": "← → o J K per navigare · Esc per uscire" + } + }, "calendar": { "toolbar": { "config": "Configurazione Calendario", diff --git a/packages/common-i18n/src/locales/ja/table.json b/packages/common-i18n/src/locales/ja/table.json index e4b573484e..8916ef9e6b 100644 --- a/packages/common-i18n/src/locales/ja/table.json +++ b/packages/common-i18n/src/locales/ja/table.json @@ -1057,6 +1057,20 @@ "duplicateCard": "カードを複製" } }, + "gallery": { + "toolbar": { + "present": "プレゼン", + "exitPresent": "プレゼンを終了" + }, + "presentation": { + "empty": "表示するレコードがありません", + "counter": "{{current}} / {{total}}", + "previous": "前へ", + "next": "次へ", + "exit": "プレゼンを終了", + "keyboardHint": "← → または J K で移動 · Esc で終了" + } + }, "calendar": { "toolbar": { "config": "カレンダー設定", diff --git a/packages/common-i18n/src/locales/ru/table.json b/packages/common-i18n/src/locales/ru/table.json index 59ce483f70..44bf015851 100644 --- a/packages/common-i18n/src/locales/ru/table.json +++ b/packages/common-i18n/src/locales/ru/table.json @@ -1072,6 +1072,20 @@ "duplicateCard": "Дублировать карточку" } }, + "gallery": { + "toolbar": { + "present": "Презентация", + "exitPresent": "Выйти из презентации" + }, + "presentation": { + "empty": "Нет записей для презентации", + "counter": "{{current}} из {{total}}", + "previous": "Назад", + "next": "Далее", + "exit": "Выйти из презентации", + "keyboardHint": "← → или J K для навигации · Esc для выхода" + } + }, "calendar": { "toolbar": { "config": "Настройка календаря", diff --git a/packages/common-i18n/src/locales/tr/table.json b/packages/common-i18n/src/locales/tr/table.json index dab277566b..4a40c064fe 100644 --- a/packages/common-i18n/src/locales/tr/table.json +++ b/packages/common-i18n/src/locales/tr/table.json @@ -1062,6 +1062,20 @@ "duplicateCard": "Kartı çoğalt" } }, + "gallery": { + "toolbar": { + "present": "Sunum", + "exitPresent": "Sunumdan çık" + }, + "presentation": { + "empty": "Sunulacak kayıt yok", + "counter": "{{current}} / {{total}}", + "previous": "Önceki", + "next": "Sonraki", + "exit": "Sunumdan çık", + "keyboardHint": "← → veya J K ile gezinin · Esc ile çıkın" + } + }, "calendar": { "toolbar": { "config": "Takvim Yapılandırması", diff --git a/packages/common-i18n/src/locales/uk/table.json b/packages/common-i18n/src/locales/uk/table.json index 7ab63c28a1..c163461f6e 100644 --- a/packages/common-i18n/src/locales/uk/table.json +++ b/packages/common-i18n/src/locales/uk/table.json @@ -1072,6 +1072,20 @@ "duplicateCard": "Дублірувати картку" } }, + "gallery": { + "toolbar": { + "present": "Презентація", + "exitPresent": "Вийти з презентації" + }, + "presentation": { + "empty": "Немає записів для презентації", + "counter": "{{current}} з {{total}}", + "previous": "Назад", + "next": "Далі", + "exit": "Вийти з презентації", + "keyboardHint": "← → або J K для навігації · Esc для виходу" + } + }, "calendar": { "toolbar": { "config": "Налаштування календаря", diff --git a/packages/common-i18n/src/locales/zh/table.json b/packages/common-i18n/src/locales/zh/table.json index 58c124d2a6..ab7fb0ee56 100644 --- a/packages/common-i18n/src/locales/zh/table.json +++ b/packages/common-i18n/src/locales/zh/table.json @@ -1057,6 +1057,20 @@ "duplicateCard": "复制卡片" } }, + "gallery": { + "toolbar": { + "present": "演示", + "exitPresent": "退出演示" + }, + "presentation": { + "empty": "没有可演示的记录", + "counter": "{{current}} / {{total}}", + "previous": "上一条", + "next": "下一条", + "exit": "退出演示", + "keyboardHint": "← → 或 J K 切换 · Esc 退出" + } + }, "calendar": { "toolbar": { "config": "日历配置", From b9f21af749f74ecefbbda1404607d201c0180b30 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 14:56:06 +0000 Subject: [PATCH 2/2] fix(app): treat contenteditable as a presentation hotkey sink happy-dom exposes contentEditable without isContentEditable, so ignore keys when the property or attribute is true. Also tidy slide padding classnames. --- .../view/gallery/components/GalleryPresentationView.tsx | 2 +- .../blocks/view/gallery/components/PresentationSlide.tsx | 2 +- .../features/app/blocks/view/gallery/utils/presentation.ts | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/components/GalleryPresentationView.tsx b/apps/nextjs-app/src/features/app/blocks/view/gallery/components/GalleryPresentationView.tsx index b1aeaaac15..fb34f5c665 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/gallery/components/GalleryPresentationView.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/components/GalleryPresentationView.tsx @@ -124,7 +124,7 @@ export const GalleryPresentationView = () => { -
+
{rowCount === 0 ? (

{t('table:gallery.presentation.empty')}

) : ( diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/components/PresentationSlide.tsx b/apps/nextjs-app/src/features/app/blocks/view/gallery/components/PresentationSlide.tsx index 98a93df10b..4a86ecd94a 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/gallery/components/PresentationSlide.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/components/PresentationSlide.tsx @@ -76,7 +76,7 @@ export const PresentationSlide = (props: IPresentationSlideProps) => { return (
{coverField && } -
+

{titleComponent}

diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/presentation.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/presentation.ts index ed3c206a37..b9e7028567 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/presentation.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/presentation.ts @@ -45,5 +45,9 @@ export const shouldIgnorePresentationHotkey = (target: EventTarget | null): bool if (!(target instanceof HTMLElement)) return false; const tag = target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; - return target.isContentEditable; + return ( + target.isContentEditable || + target.contentEditable === 'true' || + target.getAttribute('contenteditable')?.toLowerCase() === 'true' + ); };