From b7440bef39829440439a0dc50c82de597eb95b7f Mon Sep 17 00:00:00 2001 From: kostazol Date: Mon, 25 May 2026 01:51:58 +0700 Subject: [PATCH] Fix (mobile) add long-press support to shared tooltips (#1386) * Add long-press support to shared tooltips * fix bot comments * Fix controlled tooltip long-press suppression --------- Co-authored-by: Konstantin Zolin Co-authored-by: Bohdan Triapitsyn --- .../components/layout/SidebarFilesTree.tsx | 95 ++-- packages/ui/src/components/ui/tooltip.tsx | 176 ++++++- .../ui/src/components/views/FilesView.tsx | 457 ++++++++++-------- packages/ui/src/lib/i18n/messages/en.ts | 2 + packages/ui/src/lib/i18n/messages/es.ts | 2 + packages/ui/src/lib/i18n/messages/ko.ts | 2 + packages/ui/src/lib/i18n/messages/pl.ts | 2 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 + packages/ui/src/lib/i18n/messages/uk.ts | 2 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 + 10 files changed, 515 insertions(+), 227 deletions(-) diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index e9d9d0f7..1295fb03 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -19,6 +19,7 @@ import { import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; @@ -230,16 +231,25 @@ const FileRow: React.FC = ({ open={contextMenuPath === node.path} onOpenChange={(open) => setContextMenuPath(open ? node.path : null)} > - - - + + + + + + + + + {t('sidebarFilesTree.actions.fileMenuTitle')} + setContextMenuPath(null)}> {canRename && ( { e.stopPropagation(); onOpenDialog('rename', node); }}> @@ -825,30 +835,53 @@ export const SidebarFilesTree: React.FC = () => { ) : null} {canCreateFile && ( - + + + + + + + {t('sidebarFilesTree.actions.newFileTitle')} + )} {canCreateFolder && ( - + + + + + + + {t('sidebarFilesTree.actions.newFolderTitle')} + )} - + + + + + + + {t('sidebarFilesTree.actions.refreshTitle')} + diff --git a/packages/ui/src/components/ui/tooltip.tsx b/packages/ui/src/components/ui/tooltip.tsx index 945c3179..efdab8ed 100644 --- a/packages/ui/src/components/ui/tooltip.tsx +++ b/packages/ui/src/components/ui/tooltip.tsx @@ -3,6 +3,20 @@ import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip" import { cn } from "@/lib/utils" +const MOBILE_LONG_PRESS_DELAY = 600 +const MOBILE_LONG_PRESS_CLOSE_DELAY = 1600 +const MOBILE_LONG_PRESS_MOVE_TOLERANCE = 10 + +type LongPressTooltipContextValue = { + handlePointerDown: (event: React.PointerEvent) => void; + handlePointerMove: (event: React.PointerEvent) => void; + handlePointerEnd: () => void; + handleClickCapture: (event: React.MouseEvent) => void; + handleContextMenu: (event: React.MouseEvent) => void; +}; + +const LongPressTooltipContext = React.createContext(null) + type AsChildRenderProps = { render?: React.ReactElement; children?: React.ReactNode; @@ -52,11 +66,126 @@ type TooltipRootProps = React.ComponentProps & { delayDuration?: number } +type TooltipChangeEventDetails = Parameters>[1] + function Tooltip({ delayDuration, + open, + onOpenChange, ...props }: TooltipRootProps) { - const tooltip = + const [longPressOpen, setLongPressOpen] = React.useState(false) + const longPressTimeoutRef = React.useRef(null) + const closeTimeoutRef = React.useRef(null) + const startPointRef = React.useRef<{ x: number; y: number } | null>(null) + const suppressClickRef = React.useRef(false) + const controlled = open !== undefined + const tooltipOpen = controlled ? open : longPressOpen + + const clearLongPressTimeout = React.useCallback(() => { + if (longPressTimeoutRef.current !== null) { + window.clearTimeout(longPressTimeoutRef.current) + longPressTimeoutRef.current = null + } + }, []) + + const clearCloseTimeout = React.useCallback(() => { + if (closeTimeoutRef.current !== null) { + window.clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = null + } + }, []) + + const setTooltipOpen = React.useCallback((nextOpen: boolean) => { + if (!controlled) { + setLongPressOpen(nextOpen) + } + }, [controlled]) + + const contextValue = React.useMemo(() => ({ + handlePointerDown: (event) => { + if (event.pointerType !== 'touch' && event.pointerType !== 'pen') { + return + } + + clearLongPressTimeout() + clearCloseTimeout() + startPointRef.current = { x: event.clientX, y: event.clientY } + + longPressTimeoutRef.current = window.setTimeout(() => { + if (controlled) { + return + } + + suppressClickRef.current = true + setTooltipOpen(true) + }, MOBILE_LONG_PRESS_DELAY) + }, + handlePointerMove: (event) => { + const startPoint = startPointRef.current + + if (!startPoint) { + return + } + + const movedX = Math.abs(event.clientX - startPoint.x) + const movedY = Math.abs(event.clientY - startPoint.y) + + if (movedX > MOBILE_LONG_PRESS_MOVE_TOLERANCE || movedY > MOBILE_LONG_PRESS_MOVE_TOLERANCE) { + clearLongPressTimeout() + startPointRef.current = null + } + }, + handlePointerEnd: () => { + clearLongPressTimeout() + startPointRef.current = null + + if (suppressClickRef.current) { + clearCloseTimeout() + closeTimeoutRef.current = window.setTimeout(() => { + suppressClickRef.current = false + setTooltipOpen(false) + }, MOBILE_LONG_PRESS_CLOSE_DELAY) + } + }, + handleClickCapture: (event) => { + if (!suppressClickRef.current) { + return + } + + suppressClickRef.current = false + event.preventDefault() + event.stopPropagation() + }, + handleContextMenu: (event) => { + if (!suppressClickRef.current) { + return + } + + event.preventDefault() + }, + }), [clearCloseTimeout, clearLongPressTimeout, controlled, setTooltipOpen]) + + React.useEffect(() => { + return () => { + clearLongPressTimeout() + clearCloseTimeout() + } + }, [clearCloseTimeout, clearLongPressTimeout]) + + const handleOpenChange = React.useCallback((nextOpen: boolean, event: TooltipChangeEventDetails) => { + if (!controlled) { + setLongPressOpen(nextOpen) + } + + onOpenChange?.(nextOpen, event) + }, [controlled, onOpenChange]) + + const tooltip = ( + + + + ) if (delayDuration === undefined) { return tooltip @@ -68,14 +197,57 @@ function Tooltip({ function TooltipTrigger({ asChild, children, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onClickCapture, + onContextMenu, ...props }: React.ComponentProps & { asChild?: boolean }) { + const longPressTooltip = React.useContext(LongPressTooltipContext) const renderProps: AsChildRenderProps = asChild && React.isValidElement(children) ? { render: children as React.ReactElement } : { children }; return ( - + { + onPointerDown?.(event) + longPressTooltip?.handlePointerDown(event) + }} + onPointerMove={(event) => { + onPointerMove?.(event) + longPressTooltip?.handlePointerMove(event) + }} + onPointerUp={(event) => { + onPointerUp?.(event) + longPressTooltip?.handlePointerEnd() + }} + onPointerCancel={(event) => { + onPointerCancel?.(event) + longPressTooltip?.handlePointerEnd() + }} + onClickCapture={(event) => { + longPressTooltip?.handleClickCapture(event) + if (event.defaultPrevented) { + return + } + + onClickCapture?.(event) + }} + onContextMenu={(event) => { + longPressTooltip?.handleContextMenu(event) + if (event.defaultPrevented) { + return + } + + onContextMenu?.(event) + }} + {...props} + {...renderProps} + /> ) } diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index a829a780..1831d30a 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -13,6 +13,7 @@ import { import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor'; import { GoToLineDialog } from './GoToLineDialog'; import { PreviewToggleButton } from './PreviewToggleButton'; @@ -2650,6 +2651,17 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return null; } + const withTooltip = (label: React.ReactNode, trigger: React.ReactElement) => ( + + + + {trigger} + + + {label} + + ); + return (
{canEdit && textViewMode === 'edit' && ( @@ -2664,7 +2676,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { {t('filesView.editor.saved')} - ) : isDirty ? ( + ) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` }), ) : null} - + {withTooltip(autoSaveEnabled ? t('filesView.editor.autoSaveOn') : t('filesView.editor.manualSave'), + + )} )} - - - + + + + + + + + + {t('filesView.editor.openInDesktopApp')} + {openInApps.map((app) => ( = ({ mode = 'full' }) => { {!isSelectedImage && ( <> - + {withTooltip(wrapLines ? t('filesView.editor.disableLineWrap') : t('filesView.editor.enableLineWrap'), + + )} {textViewMode === 'edit' && ( <> - - + {withTooltip(t('filesView.editor.findInFile'), + + )} + {withTooltip(t('filesView.editor.goToLine'), + + )} = ({ mode = 'full' }) => { )} {isJson && ( - + withTooltip(jsonViewMode === 'tree' ? t('filesView.editor.switchToTextView') : t('filesView.editor.switchToTreeView'), + + ) )} {canCopy && ( - + }} + className="size-6 p-0 hover:bg-transparent focus-visible:bg-transparent active:bg-transparent" + title={t('filesView.editor.copyFileContents')} + aria-label={t('filesView.editor.copyFileContents')} + > + {copiedContent ? ( + + ) : ( + + )} + + ) )} {canCopyPath && ( - + }} + className="size-6 p-0 hover:bg-transparent focus-visible:bg-transparent active:bg-transparent" + title={t('filesView.editor.copyFilePathTitle', { path: displaySelectedPath })} + aria-label={t('filesView.editor.copyFilePathTitle', { path: displaySelectedPath })} + > + {copiedPath ? ( + + ) : ( + + )} + + ) )} {files.downloadFile && ( - + withTooltip(t('filesView.editor.saveFile'), + + ) )} {exitFullscreenOnly ? ( - - ) : (!isMobile && mode === 'full' && ( - + + ) + ) : (!isMobile && mode === 'full' && ( + withTooltip(isFullscreen ? t('filesView.editor.exitFullscreen') : t('filesView.editor.fullscreen'), + + ) ))}
); @@ -3113,16 +3152,23 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { {isFloatingToolbarOpen ? ( renderFloatingFileControls() ) : ( - + + + + + + + {t('filesView.editor.controlsTitle')} + )} )} @@ -3356,27 +3402,50 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { )} - - - + + + + + + + {t('filesView.tree.actions.newFileTitle')} + + + + + + + + {t('filesView.tree.actions.newFolderTitle')} + + + + + + + + {t('filesView.tree.actions.refreshTitle')} + diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index be61c3ae..37316017 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -886,6 +886,7 @@ export const dict = { 'sidebarFilesTree.actions.newFileTitle': 'New File', 'sidebarFilesTree.actions.newFolderTitle': 'New Folder', 'sidebarFilesTree.actions.refreshTitle': 'Refresh', + 'sidebarFilesTree.actions.fileMenuTitle': 'File menu', 'sidebarFilesTree.state.searching': 'Searching...', 'sidebarFilesTree.state.loading': 'Loading...', 'sidebarFilesTree.dialog.createFile.title': 'Create File', @@ -959,6 +960,7 @@ export const dict = { 'filesView.tree.search.searching': 'Searching...', 'filesView.tree.actions.newFileTitle': 'New File', 'filesView.tree.actions.newFolderTitle': 'New Folder', + 'filesView.tree.actions.refreshTitle': 'Refresh', 'filesView.editor.imageAltFallback': 'Image', 'filesView.error.jsonViewerUnavailable': 'JSON viewer unavailable', 'filesView.error.switchToTextMode': 'Switch to text mode to view raw content.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 9266fc96..42475a58 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -852,6 +852,7 @@ export const dict: Record = { "sidebarFilesTree.actions.newFileTitle": "Nuevo archivo", "sidebarFilesTree.actions.newFolderTitle": "Nueva carpeta", "sidebarFilesTree.actions.refreshTitle": "Actualizar", + "sidebarFilesTree.actions.fileMenuTitle": "Menú de archivo", "sidebarFilesTree.state.searching": "Buscando...", "sidebarFilesTree.state.loading": "Cargando...", "sidebarFilesTree.dialog.createFile.title": "Crear archivo", @@ -925,6 +926,7 @@ export const dict: Record = { "filesView.tree.search.searching": "Buscando...", "filesView.tree.actions.newFileTitle": "Nuevo archivo", "filesView.tree.actions.newFolderTitle": "Nueva carpeta", + "filesView.tree.actions.refreshTitle": "Actualizar", "filesView.editor.imageAltFallback": "Imagen", "filesView.error.jsonViewerUnavailable": "Visualizador JSON no disponible", "filesView.error.switchToTextMode": "Cambia al modo de texto para ver contenido bruto.", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 9ae11556..664eba52 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -889,6 +889,7 @@ export const dict: Record = { 'sidebarFilesTree.actions.newFileTitle': '새 파일', 'sidebarFilesTree.actions.newFolderTitle': '새 폴더', 'sidebarFilesTree.actions.refreshTitle': '새로고침', + 'sidebarFilesTree.actions.fileMenuTitle': '파일 메뉴', 'sidebarFilesTree.state.searching': '검색 중…', 'sidebarFilesTree.state.loading': '로드 중…', 'sidebarFilesTree.dialog.createFile.title': '파일 생성', @@ -962,6 +963,7 @@ export const dict: Record = { 'filesView.tree.search.searching': '검색 중…', 'filesView.tree.actions.newFileTitle': '새 파일', 'filesView.tree.actions.newFolderTitle': '새 폴더', + 'filesView.tree.actions.refreshTitle': '새로고침', 'filesView.editor.imageAltFallback': '이미지', 'filesView.error.jsonViewerUnavailable': 'JSON 뷰어를 사용할 수 없음', 'filesView.error.switchToTextMode': '원본 내용을 보려면 텍스트 모드로 전환하세요.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index d2279abf..193d2ed5 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1363,6 +1363,7 @@ export const dict: Record = { 'filesView.toast.writeFileFailed': 'Nie udało się zapisać pliku', 'filesView.tree.actions.newFileTitle': 'Nowy plik', 'filesView.tree.actions.newFolderTitle': 'Nowy folder', + 'filesView.tree.actions.refreshTitle': 'Odśwież', 'filesView.tree.search.clearAria': 'Wyczyść wyszukiwanie', 'filesView.tree.search.placeholder': 'Szukaj plików...', 'filesView.tree.search.searching': 'Wyszukiwanie...', @@ -2129,6 +2130,7 @@ export const dict: Record = { 'sidebarFilesTree.actions.newFileTitle': 'Nowy plik', 'sidebarFilesTree.actions.newFolderTitle': 'Nowy folder', 'sidebarFilesTree.actions.refreshTitle': 'Odśwież', + 'sidebarFilesTree.actions.fileMenuTitle': 'Menu pliku', 'sidebarFilesTree.dialog.cancel': 'Anuluj', 'sidebarFilesTree.dialog.confirm': 'Potwierdź', 'sidebarFilesTree.dialog.createFile.description': 'Utwórz nowy plik w {path}', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 12af130a..ed083722 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -852,6 +852,7 @@ export const dict: Record = { "sidebarFilesTree.actions.newFileTitle": "Novo arquivo", "sidebarFilesTree.actions.newFolderTitle": "Nova pasta", "sidebarFilesTree.actions.refreshTitle": "Atualizar", + "sidebarFilesTree.actions.fileMenuTitle": "Menu do arquivo", "sidebarFilesTree.state.searching": "Buscando...", "sidebarFilesTree.state.loading": "Carregando...", "sidebarFilesTree.dialog.createFile.title": "Criar arquivo", @@ -925,6 +926,7 @@ export const dict: Record = { "filesView.tree.search.searching": "Buscando...", "filesView.tree.actions.newFileTitle": "Novo arquivo", "filesView.tree.actions.newFolderTitle": "Nova pasta", + "filesView.tree.actions.refreshTitle": "Atualizar", "filesView.editor.imageAltFallback": "Imagem", "filesView.error.jsonViewerUnavailable": "Visualizador JSON não disponível", "filesView.error.switchToTextMode": "Alterne para o modo de texto para ver o conteúdo bruto.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index f4e27d1b..1ed6aa6f 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -852,6 +852,7 @@ export const dict: Record = { "sidebarFilesTree.actions.newFileTitle": "Новий файл", "sidebarFilesTree.actions.newFolderTitle": "Нова папка", "sidebarFilesTree.actions.refreshTitle": "Оновити", + "sidebarFilesTree.actions.fileMenuTitle": "Меню файлу", "sidebarFilesTree.state.searching": "Пошук...", "sidebarFilesTree.state.loading": "Завантаження...", "sidebarFilesTree.dialog.createFile.title": "Створити файл", @@ -925,6 +926,7 @@ export const dict: Record = { "filesView.tree.search.searching": "Пошук...", "filesView.tree.actions.newFileTitle": "Новий файл", "filesView.tree.actions.newFolderTitle": "Нова папка", + "filesView.tree.actions.refreshTitle": "Оновити", "filesView.editor.imageAltFallback": "Зображення", "filesView.error.jsonViewerUnavailable": "Переглядач JSON недоступний", "filesView.error.switchToTextMode": "Перейдіть у текстовий режим, щоб переглянути необроблений вміст.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index d66616ab..15232485 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -852,6 +852,7 @@ export const dict: Record = { 'sidebarFilesTree.actions.newFileTitle': '新建文件', 'sidebarFilesTree.actions.newFolderTitle': '新建文件夹', 'sidebarFilesTree.actions.refreshTitle': '刷新', + 'sidebarFilesTree.actions.fileMenuTitle': '文件菜单', 'sidebarFilesTree.state.searching': '搜索中...', 'sidebarFilesTree.state.loading': '加载中...', 'sidebarFilesTree.dialog.createFile.title': '新建文件', @@ -925,6 +926,7 @@ export const dict: Record = { 'filesView.tree.search.searching': '搜索中...', 'filesView.tree.actions.newFileTitle': '新建文件', 'filesView.tree.actions.newFolderTitle': '新建文件夹', + 'filesView.tree.actions.refreshTitle': '刷新', 'filesView.editor.imageAltFallback': '图片', 'filesView.error.jsonViewerUnavailable': 'JSON 查看器不可用', 'filesView.error.switchToTextMode': '请切换到文本模式查看原始内容。',