[ENG-578] Resizable sidebar (#2425)

* v1

* Update pnpm-lock.yaml

* Update store.ts

* Update index.tsx

* fix animation

* toggle_sidebar

* locales
This commit is contained in:
nikec 2024-05-01 16:35:09 +02:00 committed by GitHub
parent e4b0aedf64
commit 958692771d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 678 additions and 142 deletions

View file

@ -1,5 +1,6 @@
import { CheckSquare } from '@phosphor-icons/react';
import { useQueryClient } from '@tanstack/react-query';
import { SetStateAction } from 'react';
import { useNavigate } from 'react-router';
import {
auth,
@ -30,6 +31,7 @@ import {
useExplorerOperatingSystem
} from '../../Explorer/useExplorerOperatingSystem';
import Setting from '../../settings/Setting';
import { useSidebarContext } from './SidebarLayout/Context';
export default () => {
const buildInfo = useBridgeQuery(['buildInfo']);
@ -39,10 +41,20 @@ export default () => {
const platform = usePlatform();
const navigate = useNavigate();
const sidebar = useSidebarContext();
const popover = usePopover();
function handleOpenChange(action: SetStateAction<boolean>) {
const open = typeof action === 'boolean' ? action : !popover.open;
popover.setOpen(open);
sidebar.onLockedChange(open);
}
return (
<Popover
popover={usePopover()}
className="p-4 focus:outline-none"
popover={{ ...popover, setOpen: handleOpenChange }}
className="z-[100] p-4 focus:outline-none"
trigger={
<h1 className="ml-1 w-full text-[7pt] text-sidebar-inkFaint/50">
v{buildInfo.data?.version || '-.-.-'} - {buildInfo.data?.commit || 'dev'}

View file

@ -11,6 +11,7 @@ import {
import { Button, PopoverClose, toast, Tooltip } from '@sd/ui';
import { useIsDark, useLocale } from '~/hooks';
import { useSidebarContext } from '../SidebarLayout/Context';
import { getSidebarStore, useSidebarStore } from '../store';
import IsRunningJob from './IsRunningJob';
import JobGroup from './JobGroup';
@ -49,6 +50,8 @@ export function JobManager() {
const [toggleConfirmation, setToggleConfirmation] = useState(false);
const store = useSidebarStore();
const sidebar = useSidebarContext();
const jobGroups = useLibraryQuery(['jobs.reports']);
const progress = useJobProgress(jobGroups.data);
@ -81,16 +84,18 @@ export function JobManager() {
return (
<div className="h-full overflow-hidden pb-10">
<div className="z-20 flex h-9 w-full items-center rounded-t-md border-b border-app-line/50 bg-app-button/30 px-2">
<Tooltip label={t('pin')}>
<Button
onClick={() => {
getSidebarStore().pinJobManager = !store.pinJobManager;
}}
size="icon"
>
<PushPin weight={store.pinJobManager ? 'fill' : 'regular'} size={16} />
</Button>
</Tooltip>
{!sidebar.collapsed && (
<Tooltip label={t('pin')}>
<Button
onClick={() => {
getSidebarStore().pinJobManager = !store.pinJobManager;
}}
size="icon"
>
<PushPin weight={store.pinJobManager ? 'fill' : 'regular'} size={16} />
</Button>
</Tooltip>
)}
<span className="ml-1 font-medium ">{t('recent_jobs')}</span>
<div className="grow" />
{toggleConfirmation ? (

View file

@ -0,0 +1,18 @@
import { createContext, useContext } from 'react';
interface SidebarContextProps {
show: boolean;
locked: boolean;
collapsed: boolean;
onLockedChange: (val: boolean) => void;
}
export const SidebarContext = createContext<SidebarContextProps | null>(null);
export const useSidebarContext = () => {
const ctx = useContext(SidebarContext);
if (ctx === null) throw new Error('SidebarContext.Provider not found!');
return ctx;
};

View file

@ -1,4 +1,5 @@
import clsx from 'clsx';
import { SetStateAction } from 'react';
import { Controller } from 'react-hook-form';
import { auth, useBridgeMutation, useZodForm } from '@sd/client';
import { Button, Form, Popover, TextAreaField, toast, usePopover, z } from '@sd/ui';
@ -6,6 +7,8 @@ import i18n from '~/app/I18n';
import { LoginButton } from '~/components/LoginButton';
import { useLocale } from '~/hooks';
import { useSidebarContext } from './Context';
const schema = z.object({
message: z.string().min(1, { message: i18n.t('feedback_is_required') }),
emoji: z.number().min(0).max(3, { message: 'Please select an emoji' })
@ -13,7 +16,7 @@ const schema = z.object({
const EMOJIS = ['🤩', '😀', '🙁', '😭'];
export default function () {
export function FeedbackPopover() {
const { t } = useLocale();
const sendFeedback = useBridgeMutation(['api.sendFeedback'], {
@ -33,18 +36,27 @@ export default function () {
}
});
const sidebar = useSidebarContext();
const emojiError = form.formState.errors.emoji?.message;
const popover = usePopover();
const authState = auth.useStateSnapshot();
function handleOpenChange(action: SetStateAction<boolean>) {
const open = typeof action === 'boolean' ? action : !popover.open;
popover.setOpen(open);
sidebar.onLockedChange(open);
}
return (
<Popover
popover={popover}
popover={{ ...popover, setOpen: handleOpenChange }}
trigger={
<Button variant="outline" className="flex items-center gap-1">
<p className="text-[11px] font-normal text-sidebar-inkFaint">{t('feedback')}</p>
</Button>
}
className="z-[100]"
>
<Form
form={form}

View file

@ -3,32 +3,26 @@ import { Gear } from '@phosphor-icons/react';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import {
JobManagerContextProvider,
LibraryContextProvider,
Procedures,
useClientContext,
useDebugState,
useLibrarySubscription,
useUnsafeStreamedQuery
useLibrarySubscription
} from '@sd/client';
import { Button, ButtonLink, Popover, Tooltip, usePopover } from '@sd/ui';
import { Button, ButtonLink, Tooltip } from '@sd/ui';
import { useKeysMatcher, useLocale, useShortcut } from '~/hooks';
import { useRoutingContext } from '~/RoutingContext';
import { usePlatform } from '~/util/Platform';
import DebugPopover from '../DebugPopover';
import { IsRunningJob, JobManager } from '../JobManager';
import { useSidebarStore } from '../store';
import FeedbackButton from './FeedbackButton';
import { FeedbackPopover } from './FeedbackPopover';
import { JobManagerPopover } from './JobManagerPopover';
export default () => {
const { library } = useClientContext();
const { visible } = useRoutingContext();
const { t } = useLocale();
const debugState = useDebugState();
const navigate = useNavigate();
const symbols = useKeysMatcher(['Meta', 'Shift']);
const store = useSidebarStore();
useShortcut('navToSettings', (e) => {
e.stopPropagation();
@ -38,30 +32,20 @@ export default () => {
const updater = usePlatform().updater;
const updaterState = updater?.useSnapshot();
const jobManagerPopover = usePopover();
useShortcut('toggleJobManager', () => jobManagerPopover.setOpen((open) => !open));
return (
<div className="space-y-2">
{updater && updaterState && (
<>
{updaterState.status === 'updateAvailable' && (
<Button
variant="outline"
className="w-full"
onClick={updater.installUpdate}
>
{t('install_update')}
</Button>
)}
</>
{updater && updaterState?.status === 'updateAvailable' && (
<Button variant="outline" className="w-full" onClick={updater.installUpdate}>
{t('install_update')}
</Button>
)}
{library && (
<LibraryContextProvider library={library}>
<SyncStatusIndicator />
</LibraryContextProvider>
)}
<div className="flex w-full items-center justify-between">
<div className="flex">
<ButtonLink
@ -78,40 +62,12 @@ export default () => {
<Gear className="size-5" />
</Tooltip>
</ButtonLink>
<JobManagerContextProvider>
<Popover
popover={{
...jobManagerPopover,
open: jobManagerPopover.open || (store.pinJobManager && visible)
}}
trigger={
<Button
id="job-manager-button"
size="icon"
variant="subtle"
className="text-sidebar-inkFaint ring-offset-sidebar radix-state-open:bg-sidebar-selected/50"
disabled={!library}
>
{library && (
<Tooltip
label={t('recent_jobs')}
position="top"
keybinds={[symbols.Meta.icon, 'J']}
>
<IsRunningJob />
</Tooltip>
)}
</Button>
}
>
<div className="block h-96 w-[430px]">
<JobManager />
</div>
</Popover>
</JobManagerContextProvider>
<JobManagerPopover />
</div>
<FeedbackButton />
<FeedbackPopover />
</div>
{debugState.enabled && <DebugPopover />}
</div>
);

View file

@ -0,0 +1,75 @@
import { SetStateAction } from 'react';
import { JobManagerContextProvider, useClientContext } from '@sd/client';
import { Button, Popover, Tooltip, usePopover } from '@sd/ui';
import { useKeysMatcher, useLocale, useShortcut } from '~/hooks';
import { useRoutingContext } from '~/RoutingContext';
import { IsRunningJob, JobManager } from '../JobManager';
import { useSidebarStore } from '../store';
import { useSidebarContext } from './Context';
export function JobManagerPopover() {
const { t } = useLocale();
const { library } = useClientContext();
const { visible } = useRoutingContext();
const { Meta } = useKeysMatcher(['Meta', 'Shift']);
const { pinJobManager } = useSidebarStore();
const sidebar = useSidebarContext();
const popover = usePopover();
function handleOpenChange(action: SetStateAction<boolean>) {
const open = typeof action === 'boolean' ? action : !popover.open;
popover.setOpen(open);
sidebar.onLockedChange(open);
}
useShortcut('toggleJobManager', () => {
const open = !popover.open;
if (sidebar.collapsed && !sidebar.show && open) {
sidebar.onLockedChange(true);
// Wait for the sidebar to open
setTimeout(() => handleOpenChange(open), 120);
} else {
handleOpenChange(open);
}
});
return (
<JobManagerContextProvider>
<Popover
popover={{
open: popover.open || (pinJobManager && visible),
setOpen: handleOpenChange
}}
trigger={
<Button
id="job-manager-button"
size="icon"
variant="subtle"
className="text-sidebar-inkFaint ring-offset-sidebar radix-state-open:bg-sidebar-selected/50"
disabled={!library}
>
{library && (
<Tooltip
label={t('recent_jobs')}
position="top"
keybinds={[Meta.icon, 'J']}
>
<IsRunningJob />
</Tooltip>
)}
</Button>
}
className="z-[100]"
// overlay
>
<div className="block h-96 w-[430px]">
<JobManager />
</div>
</Popover>
</JobManagerContextProvider>
);
}

View file

@ -5,10 +5,13 @@ import { dialogManager, Dropdown, DropdownMenu } from '@sd/ui';
import { useLocale } from '~/hooks';
import CreateDialog from '../../../settings/node/libraries/CreateDialog';
import { useSidebarContext } from './Context';
export default () => {
const { library, libraries, currentLibraryId } = useClientContext();
const sidebar = useSidebarContext();
const { t } = useLocale();
return (
@ -31,8 +34,10 @@ export default () => {
}
// we override the sidebar dropdown item's hover styles
// because the dark style clashes with the sidebar
className="mt-1 shadow-none data-[side=bottom]:slide-in-from-top-2 dark:divide-menu-selected/30 dark:border-sidebar-line dark:bg-sidebar-box"
className="z-[100] mt-1 shadow-none data-[side=bottom]:slide-in-from-top-2 dark:divide-menu-selected/30 dark:border-sidebar-line dark:bg-sidebar-box"
alignToTrigger
// Timeout because of race conditions when opening the dropdown from a open popover.
onOpenChange={(open) => setTimeout(() => sidebar.onLockedChange(open))}
>
{libraries.data?.map((lib) => (
<DropdownMenu.Item

View file

@ -1,58 +1,268 @@
import { SidebarSimple } from '@phosphor-icons/react';
import clsx from 'clsx';
import { PropsWithChildren, useEffect } from 'react';
import { motion, useAnimationControls, Variants } from 'framer-motion';
import { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react';
import { useKey } from 'rooks';
import { Button, Kbd, Resizable, ResizableHandle, Tooltip, useResizableContext } from '@sd/ui';
import { MacTrafficLights } from '~/components';
import { useOperatingSystem, useShowControls } from '~/hooks';
import { useWindowState } from '~/hooks/useWindowState';
import { useLocale, useOperatingSystem, useShortcut, useShowControls } from '~/hooks';
import { layoutStore, useLayoutStore } from '../../store';
import { getSidebarStore } from '../store';
import { SidebarContext } from './Context';
import Footer from './Footer';
import LibrariesDropdown from './LibrariesDropdown';
export default (props: PropsWithChildren) => {
const TRANSITION_EASE = [0.25, 1, 0.5, 1];
export default ({ children }: PropsWithChildren) => {
const os = useOperatingSystem();
const showControls = useShowControls();
const windowState = useWindowState();
//prevent sidebar scrolling with keyboard
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const arrows = ['ArrowUp', 'ArrowDown'];
if (arrows.includes(e.key)) {
e.preventDefault();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
const { sidebar } = useLayoutStore();
// Prevent scroll with arrow up/down keys
useKey(['ArrowUp', 'ArrowDown'], (e) => e.preventDefault());
return (
<div
className={clsx(
'relative flex min-h-full w-44 shrink-0 grow-0 flex-col gap-2.5 border-r border-sidebar-divider bg-sidebar px-2.5 pb-2 transition-[padding-top] ease-linear motion-reduce:transition-none',
os === 'macOS' && windowState.isFullScreen
? '-mt-2 pt-[8.75px] duration-100'
: 'pt-2.5 duration-75',
os === 'macOS' || showControls.transparentBg
? 'bg-opacity-[0.65]'
: 'bg-opacity-[1]'
)}
<Resizable
min={176}
max={300}
initial={sidebar.size}
collapsed={sidebar.collapsed}
onCollapseChange={(val) => (layoutStore.sidebar.collapsed = val)}
onResizeEnd={({ position }) => (layoutStore.sidebar.size = position)}
>
{showControls.isEnabled && <MacTrafficLights className="z-50 mb-1" />}
<div
className={clsx(
'bg-sidebar',
(os === 'macOS' || showControls.transparentBg) && 'bg-opacity-[0.65]'
)}
>
<SidebarSize />
<SidebarContent>
{showControls.isEnabled && <MacTrafficLights className="z-50 mb-1" />}
{os === 'macOS' && (
<div
data-tauri-drag-region
className={clsx(
'w-full shrink-0 transition-[height] ease-linear motion-reduce:transition-none',
windowState.isFullScreen ? 'h-0 duration-100' : 'h-4 duration-75'
)}
/>
)}
<LibrariesDropdown />
<div className="no-scrollbar mask-fade-out flex grow flex-col space-y-5 overflow-x-hidden overflow-y-scroll pb-10">
{props.children}
<div className="grow" />
<SidebarControls />
<LibrariesDropdown />
<div className="no-scrollbar mask-fade-out flex grow flex-col space-y-5 overflow-x-hidden overflow-y-scroll pb-10">
{children}
<div className="grow" />
</div>
<Footer />
</SidebarContent>
</div>
<Footer />
</div>
</Resizable>
);
};
const SidebarSize = () => {
const resizable = useResizableContext();
const controls = useAnimationControls();
useEffect(() => {
if (resizable.collapsed) return;
controls.start({ width: resizable.position, transition: { duration: 0 } });
}, [controls, resizable.position, resizable.collapsed]);
useEffect(() => {
controls.start({ width: resizable.size, transition: { ease: TRANSITION_EASE } });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controls, resizable.collapsed]);
return <motion.div initial={{ width: resizable.size }} animate={controls} />;
};
const SidebarContent = ({ children }: PropsWithChildren) => {
const resizable = useResizableContext();
const ref = useRef<HTMLDivElement>(null);
const [show, setShow] = useState(!resizable.collapsed);
// Used to prevent any changes to the open state
// e.g. when popovers are open from the sidebar
const [locked, setLocked] = useState(false);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const controls = useAnimationControls();
const variants: Variants = {
hide: { left: -resizable.position + 12, transition: { ease: TRANSITION_EASE } },
show: { left: 0, transition: { ease: TRANSITION_EASE } }
};
const toggleSidebar = useCallback(
(show: boolean) => {
controls.start(show ? 'show' : 'hide');
setShow(show);
},
[controls]
);
useEffect(() => {
const node = ref.current;
if (!node || !resizable.collapsed) return;
const handleMouseMove = (e: MouseEvent) => {
const { clientX, clientY } = e;
const rect = node.getBoundingClientRect();
const isHoveringX = clientX >= rect.left && clientX <= rect.right;
const isHoveringY = clientY >= rect.top && clientY <= rect.bottom;
setHovered(isHoveringX && isHoveringY);
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, [resizable.collapsed]);
useEffect(() => {
const node = ref.current;
if (!node || !resizable.collapsed) return;
const handleFocus = (focused: boolean) => setFocused(focused);
node.addEventListener('focusin', () => handleFocus(true));
node.addEventListener('focusout', () => handleFocus(false));
return () => {
node.removeEventListener('focusin', () => handleFocus(true));
node.removeEventListener('focusout', () => handleFocus(false));
};
}, [resizable.collapsed]);
useEffect(() => {
if (!resizable.collapsed || resizable.isDragging || hovered || locked) return;
toggleSidebar(focused);
}, [focused, hovered, resizable.collapsed, resizable.isDragging, toggleSidebar, locked]);
useEffect(() => {
if (!resizable.collapsed) return;
setTimeout(() => toggleSidebar(locked));
}, [toggleSidebar, locked, resizable.collapsed]);
useEffect(() => {
if (!resizable.collapsed || resizable.isDragging || locked) return;
// Timeout toggle as LibrariesDropdown triggers a focus on close which causes a flicker
setTimeout(() => toggleSidebar(hovered));
}, [hovered, resizable.collapsed, resizable.isDragging, toggleSidebar, locked]);
useEffect(() => {
setTimeout(() => toggleSidebar(!resizable.collapsed));
// Temporary solution until we have a better way to handle pinned popovers
if (resizable.collapsed) getSidebarStore().pinJobManager = false;
setLocked(false);
setHovered(false);
setFocused(false);
}, [resizable.collapsed, toggleSidebar]);
useShortcut('toggleSidebar', () => (layoutStore.sidebar.collapsed = !resizable.collapsed), {
disabled: locked || resizable.isDragging
});
return (
<SidebarContext.Provider
value={{ show, locked, onLockedChange: setLocked, collapsed: resizable.collapsed }}
>
<motion.div
ref={ref}
initial={show ? 'show' : 'hide'}
animate={controls}
variants={variants}
className={clsx('fixed inset-y-0 z-[100]', resizable.collapsed && 'p-1 pr-3')}
style={{
// We add 16px from the padding on the x-axis
width: resizable.position + (show && resizable.collapsed ? 16 : 0)
}}
>
<nav
className={clsx(
'relative z-[51] flex h-full flex-col gap-2.5 p-2.5 pb-2',
// Uncomment if SidebarControls are removed
// 'transition-[padding-top] ease-linear motion-reduce:transition-none',
// os === 'macOS' && !windowState.isFullScreen && 'pt-2.5',
resizable.collapsed
? 'rounded-md border border-app-line bg-sidebar shadow'
: null
)}
>
{children}
<ResizeHandle />
</nav>
</motion.div>
</SidebarContext.Provider>
);
};
function SidebarControls() {
const { t } = useLocale();
const os = useOperatingSystem();
const { sidebar } = useLayoutStore();
if (os !== 'macOS') return null;
return (
<div className="flex justify-end">
<Tooltip
label={!sidebar.collapsed ? t('hide_sidebar') : t('lock_sidebar')}
keybinds={['[']}
>
<Button
size="icon"
onClick={() => (layoutStore.sidebar.collapsed = !layoutStore.sidebar.collapsed)}
>
<SidebarSimple className="size-[18px]" />
</Button>
</Tooltip>
</div>
);
}
function ResizeHandle() {
const { t } = useLocale();
const { sidebar } = useLayoutStore();
const resizable = useResizableContext();
const [cursor, setCursor] = useState<{ x: number; y: number }>();
const [collapse, setCollapse] = useState(false);
useEffect(() => setCollapse(false), [resizable.position]);
return (
<Tooltip
align="start"
position="right"
disableHoverableContent
alignOffset={(cursor?.y ?? 0) - (sidebar.collapsed ? 39 : 35)}
className={clsx('!absolute inset-y-2 -right-1')}
label={
resizable.isDragging ? null : (
<div className="flex flex-col items-start">
<div>{t('drag_to_resize')}</div>
<div className="flex items-center gap-1">
<span>{t(sidebar.collapsed ? 'click_to_lock' : 'click_to_hide')}</span>
<Kbd>[</Kbd>
</div>
</div>
)
}
>
<ResizableHandle
className="h-full after:rounded-full"
onMouseOver={(e) => setCursor({ x: e.clientX, y: e.clientY })}
onMouseDown={() => setCollapse(true)}
onMouseUp={() => collapse && (layoutStore.sidebar.collapsed = !sidebar.collapsed)}
/>
</Tooltip>
);
}

View file

@ -61,7 +61,7 @@ const Layout = () => {
ref={layoutRef}
className={clsx(
// App level styles
'flex h-screen cursor-default select-none overflow-hidden text-ink',
'flex h-screen select-none overflow-hidden text-ink',
os === 'macOS' && [
'has-blur-effects',
!windowState.isFullScreen &&

View file

@ -0,0 +1,10 @@
import { proxy, useSnapshot } from 'valtio';
import { valtioPersist } from '@sd/client';
const state = proxy({
sidebar: { size: 180, collapsed: false }
});
export const layoutStore = valtioPersist('sd-layout', state);
export const useLayoutStore = () => useSnapshot(layoutStore);

View file

@ -4,10 +4,18 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
import useResizeObserver from 'use-resize-observer';
import { useSelector } from '@sd/client';
import { Tooltip } from '@sd/ui';
import { useKeyMatcher, useLocale, useShortcut, useShowControls } from '~/hooks';
import {
useKeyMatcher,
useLocale,
useOperatingSystem,
useShortcut,
useShowControls,
useWindowState
} from '~/hooks';
import { useTabsContext } from '~/TabsContext';
import { explorerStore } from '../Explorer/store';
import { useLayoutStore } from '../Layout/store';
import { useTopBarContext } from './Context';
import { NavigationButtons } from './NavigationButtons';
@ -15,11 +23,17 @@ import { NavigationButtons } from './NavigationButtons';
const TopBar = () => {
const transparentBg = useShowControls().transparentBg;
const isDragSelecting = useSelector(explorerStore, (s) => s.isDragSelecting);
const ref = useRef<HTMLDivElement>(null);
const tabs = useTabsContext();
const ctx = useTopBarContext();
const windowState = useWindowState();
const platform = useOperatingSystem();
const layoutStore = useLayoutStore();
useResizeObserver({
ref,
box: 'border-box',
@ -63,7 +77,11 @@ const TopBar = () => {
className={clsx(
'flex h-12 items-center gap-3.5 overflow-hidden px-3.5',
'duration-250 transition-[background-color,border-color] ease-out',
isDragSelecting && 'pointer-events-none'
isDragSelecting && 'pointer-events-none',
platform === 'macOS' &&
!windowState.isFullScreen &&
layoutStore.sidebar.collapsed &&
'pl-20'
)}
>
<div

View file

@ -29,7 +29,8 @@ export const Component = () => {
{ shortcut: 'newTab', description: t('open_new_tab') },
{ shortcut: 'closeTab', description: t('close_current_tab') },
{ shortcut: 'newTab', description: t('switch_to_next_tab') },
{ shortcut: 'previousTab', description: t('switch_to_previous_tab') }
{ shortcut: 'previousTab', description: t('switch_to_previous_tab') },
{ shortcut: 'toggleSidebar', description: t('toggle_sidebar') }
]
},
{

View file

@ -4,6 +4,7 @@ button {
}
body {
@apply cursor-default;
-webkit-user-select: none;
// font-family: 'InterVariable', sans-serif;
}

View file

@ -147,6 +147,9 @@ const shortcuts = {
},
explorerRight: {
all: ['ArrowRight']
},
toggleSidebar: {
all: ['[']
}
} satisfies Record<string, Shortcut>;

View file

@ -479,6 +479,12 @@
"toggle_metadata": "Паказаць метададзеныя",
"toggle_path_bar": "Адкрыць адрасны радок",
"toggle_quick_preview": "Адкрыць перадпрагляд",
"toggle_sidebar": "Пераключыць бакавую панэль",
"lock_sidebar": "Бакавая панэль заблакаваць",
"hide_sidebar": "Схаваць бакавую панэль",
"drag_to_resize": "Перацягнуць, каб змяніць памер",
"click_to_hide": "Націсніце, каб схаваць",
"click_to_lock": "Націсніце, каб заблакаваць",
"tools": "Інструменты",
"trash": "Сметніца",
"type": "Тып",

View file

@ -479,6 +479,12 @@
"toggle_metadata": "Metadaten umschalten",
"toggle_path_bar": "Pfadleiste umschalten",
"toggle_quick_preview": "Schnellvorschau umschalten",
"toggle_sidebar": "Seitenleiste umschalten",
"lock_sidebar": "Seitenleiste sperren",
"hide_sidebar": "Seitenleiste ausblenden",
"drag_to_resize": "Zum Ändern der Größe ziehen",
"click_to_hide": "Zum Ausblenden klicken",
"click_to_lock": "Zum Sperren klicken",
"tools": "Werkzeuge",
"trash": "Müll",
"type": "Typ",

View file

@ -479,6 +479,12 @@
"toggle_metadata": "Toggle metadata",
"toggle_path_bar": "Toggle path bar",
"toggle_quick_preview": "Toggle quick preview",
"toggle_sidebar": "Toggle sidebar",
"lock_sidebar": "Lock sidebar",
"hide_sidebar": "Hide sidebar",
"drag_to_resize": "Drag to resize",
"click_to_hide": "Click to hide",
"click_to_lock": "Click to lock",
"tools": "Tools",
"trash": "Trash",
"type": "Type",

View file

@ -479,6 +479,12 @@
"toggle_metadata": "Alternar metadatos",
"toggle_path_bar": "Alternar barra de ruta",
"toggle_quick_preview": "Alternar vista rápida",
"toggle_sidebar": "Alternar barra lateral",
"lock_sidebar": "Bloquear barra lateral",
"hide_sidebar": "Ocultar barra lateral",
"drag_to_resize": "Arrastrar para redimensionar",
"click_to_hide": "Clic para ocultar",
"click_to_lock": "Clic para bloquear",
"tools": "Herramientas",
"trash": "Basura",
"type": "Tipo",

View file

@ -479,6 +479,12 @@
"toggle_metadata": "Activer/désactiver les métadonnées",
"toggle_path_bar": "Activer/désactiver la barre de chemin",
"toggle_quick_preview": "Activer/désactiver l'aperçu rapide",
"toggle_sidebar": "Basculer la barre latérale",
"lock_sidebar": "Verrouiller la barre latérale",
"hide_sidebar": "Masquer la barre latérale",
"drag_to_resize": "Faites glisser pour redimensionner",
"click_to_hide": "Cliquez pour masquer",
"click_to_lock": "Cliquez pour verrouiller",
"tools": "Outils",
"trash": "Poubelle",
"type": "Type",

View file

@ -479,6 +479,12 @@
"toggle_metadata": "Mostra/nascondi metadati",
"toggle_path_bar": "Mostra/nascondi barra del percorso",
"toggle_quick_preview": "Mostra/nascondi anteprima rapida",
"toggle_sidebar": "Attiva/disattiva la barra laterale",
"lock_sidebar": "Blocca la barra laterale",
"hide_sidebar": "Nascondi la barra laterale",
"drag_to_resize": "Trascina per ridimensionare",
"click_to_hide": "Clicca per nascondere",
"click_to_lock": "Clicca per bloccare",
"tools": "Utensili",
"trash": "Spazzatura",
"type": "Tipo",

View file

@ -479,6 +479,12 @@
"toggle_metadata": "詳細パネルを開閉",
"toggle_path_bar": "パスバーの表示切り替え",
"toggle_quick_preview": "クイック プレビューを表示",
"toggle_sidebar": "サイドバーを切り替える",
"lock_sidebar": "サイドバーをロックする",
"hide_sidebar": "サイドバーを非表示にする",
"drag_to_resize": "サイズ変更にドラッグ",
"click_to_hide": "非表示にするにはクリック",
"click_to_lock": "ロックするにはクリック",
"tools": "ツール",
"trash": "ごみ",
"type": "種類",

View file

@ -479,6 +479,12 @@
"toggle_metadata": "Metadata in-/uitschakelen",
"toggle_path_bar": "Padbalk in-/uitschakelen",
"toggle_quick_preview": "Snelle voorvertoning in-/uitschakelen",
"toggle_sidebar": "Zijbalk omwisselen",
"lock_sidebar": "Zijbalk vergrendelen",
"hide_sidebar": "Zijbalk verbergen",
"drag_to_resize": "Verslepen om te wijzigen",
"click_to_hide": "Klik om te verbergen",
"click_to_lock": "Klik om te vergrendelen",
"tools": "Hulpmiddelen",
"trash": "Afval",
"type": "Type",

View file

@ -479,6 +479,12 @@
"toggle_metadata": "Показать метаданные",
"toggle_path_bar": "Открыть адресную строку",
"toggle_quick_preview": "Открыть предпросмотр",
"toggle_sidebar": "Переключить боковую панель",
"lock_sidebar": "Заблокировать боковую панель",
"hide_sidebar": "Скрыть боковую панель",
"drag_to_resize": "Перетащите, чтобы изменить размер",
"click_to_hide": "Щелкните, чтобы скрыть",
"click_to_lock": "Щелкните, чтобы заблокировать",
"tools": "Инструменты",
"trash": "Корзина",
"type": "Тип",

View file

@ -479,6 +479,12 @@
"toggle_metadata": "Meta verileri aç/kapat",
"toggle_path_bar": "Yol çubuğunu aç/kapat",
"toggle_quick_preview": "Hızlı önizlemeyi aç/kapat",
"toggle_sidebar": "Kenar çubuğunu değiştir",
"lock_sidebar": "Kenar çubuğunu kilitle",
"hide_sidebar": "Kenar çubuğunu gizle",
"drag_to_resize": "Boyutlandırmak için sürükleyin",
"click_to_hide": "Gizlemek için tıklayın",
"click_to_lock": "Kilitlemek için tıklayın",
"tools": "Aletler",
"trash": "Çöp",
"type": "Tip",

View file

@ -481,6 +481,12 @@
"toggle_metadata": "切换元数据",
"toggle_path_bar": "切换显示路径栏",
"toggle_quick_preview": "切换快速预览",
"toggle_sidebar": "切换侧边栏",
"lock_sidebar": "锁定侧边栏",
"hide_sidebar": "隐藏侧边栏",
"drag_to_resize": "拖动以调整大小",
"click_to_hide": "点击隐藏",
"click_to_lock": "点击锁定",
"tools": "工具",
"trash": "垃圾",
"type": "类型",

View file

@ -479,6 +479,12 @@
"toggle_metadata": "切換元數據",
"toggle_path_bar": "切換顯示路徑列",
"toggle_quick_preview": "切換快速預覽",
"toggle_sidebar": "切換側邊欄",
"lock_sidebar": "鎖定側邊欄",
"hide_sidebar": "隱藏側邊欄",
"drag_to_resize": "拖曳調整大小",
"click_to_hide": "點擊隱藏",
"click_to_lock": "點擊鎖定",
"tools": "工具",
"trash": "垃圾",
"type": "類型",

View file

@ -40,6 +40,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-loading-icons": "^1.1.0",
"react-resizable-layout": "^0.7.2",
"react-router-dom": "=6.20.1",
"sonner": "^1.0.3",
"use-debounce": "^9.0.4",

View file

@ -72,7 +72,11 @@ const Root = (props: PropsWithChildren<DropdownMenuProps>) => {
<RadixDM.Portal>
<Suspense fallback={null}>
<RadixDM.Content
className={clsx(contextMenuClassNames, width && '!min-w-0', className)}
className={clsx(
contextMenuClassNames,
width && '!min-w-0 max-w-none',
className
)}
align="start"
style={{ width }}
{...contentProps}

View file

@ -0,0 +1,114 @@
'use client';
import clsx from 'clsx';
import {
createContext,
HTMLAttributes,
PropsWithChildren,
useContext,
useEffect,
useRef
} from 'react';
import {
Resizable as ResizableType,
useResizable,
UseResizableProps
} from 'react-resizable-layout';
type ResizableContextProps = ResizableType & { size: number; collapsed: boolean };
const ResizableContext = createContext<ResizableContextProps | null>(null);
const useResizableContext = () => {
const context = useContext(ResizableContext);
if (!context) throw new Error('ResizableContext.Provider not found!');
return context;
};
interface ResizableProps extends Omit<PropsWithChildren<UseResizableProps>, 'axis'> {
axis?: UseResizableProps['axis'];
collapsed?: boolean;
onCollapseChange?: (val: boolean) => void;
}
const Resizable = ({ axis = 'x', ...props }: ResizableProps) => {
const resizable = useResizable({ axis, ...props });
const minSizeClientX = useRef<number | null>(null);
useEffect(() => {
if (!props.onCollapseChange || !resizable.isDragging || !props.min) return;
const handleMouseMove = (e: MouseEvent) => {
if (minSizeClientX.current === null) {
if (props.min === resizable.position && !props.collapsed) {
minSizeClientX.current = e.clientX;
}
return;
}
const half = minSizeClientX.current / 2;
if (e.clientX < half && !props.collapsed) props.onCollapseChange!(true);
else if (e.clientX > half && props.collapsed) props.onCollapseChange!(false);
};
document.addEventListener('mousemove', handleMouseMove);
return () => document.removeEventListener('mousemove', handleMouseMove);
}, [
props.min,
props.collapsed,
props.onCollapseChange,
resizable.isDragging,
resizable.position
]);
useEffect(() => {
if (!resizable.isDragging) {
minSizeClientX.current = null;
document.body.style.cursor = '';
} else {
const cursor = axis === 'x' ? 'col-resize' : 'row-resize';
document.body.style.setProperty('cursor', cursor, 'important');
}
}, [resizable.isDragging, axis]);
return (
<ResizableContext.Provider
value={{
...resizable,
size: props.collapsed ? 0 : resizable.position,
collapsed: !!props.collapsed
}}
>
{props.children}
</ResizableContext.Provider>
);
};
const ResizablePanel = (props: HTMLAttributes<HTMLDivElement>) => {
const resizable = useResizableContext();
return <div style={{ width: resizable.size }} {...props} />;
};
const ResizableHandle = ({ className, ...props }: HTMLAttributes<HTMLDivElement>) => {
const resizable = useResizableContext();
return (
<div
className={clsx(
'w-2',
'aria-[orientation=horizontal]:cursor-row-resize aria-[orientation=vertical]:cursor-col-resize',
'after:absolute after:inset-y-0 after:left-0.5 after:w-0.5 after:bg-accent after:opacity-0 after:transition-opacity hover:after:opacity-100',
resizable.isDragging && 'after:opacity-100',
className
)}
{...props}
{...resizable.separatorProps}
/>
);
};
export { Resizable, ResizableHandle, ResizablePanel, useResizableContext };

View file

@ -5,8 +5,14 @@ import clsx from 'clsx';
import { PropsWithChildren, ReactNode } from 'react';
import { ModifierKeys } from './keys';
import { tw } from './utils';
export interface TooltipProps extends PropsWithChildren {
export const Kbd = tw.kbd`h-4.5 flex items-center justify-center rounded-md border border-app-selected bg-app-selected/50 px-1.5 py-0.5 text-[10px] text-ink`;
export interface TooltipProps
extends PropsWithChildren,
Pick<TooltipPrimitive.TooltipProps, 'disableHoverableContent'>,
Pick<TooltipPrimitive.TooltipContentProps, 'alignOffset' | 'sideOffset' | 'align'> {
label: ReactNode;
position?: 'top' | 'right' | 'bottom' | 'left';
className?: string;
@ -33,7 +39,7 @@ const separateKeybinds = (keybinds: TooltipProps['keybinds']): TooltipProps['key
export const Tooltip = ({ position = 'bottom', ...props }: TooltipProps) => {
return (
<TooltipPrimitive.Root>
<TooltipPrimitive.Root disableHoverableContent={props.disableHoverableContent}>
<TooltipPrimitive.Trigger asChild>
{props.asChild ? (
props.children
@ -44,24 +50,22 @@ export const Tooltip = ({ position = 'bottom', ...props }: TooltipProps) => {
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side={position}
align={props.align}
sideOffset={props.sideOffset}
alignOffset={props.alignOffset}
className={clsx(
'TooltipContent z-50 m-2 mt-1 flex max-w-[200px] select-text items-center gap-2 break-words rounded border border-app-line bg-app-box px-2 py-1 text-center text-xs text-ink',
'TooltipContent z-[101] m-2 mt-1 flex max-w-[200px] select-text items-center gap-2 break-words rounded border border-app-line bg-app-box px-2 py-1 text-center text-xs text-ink',
props.tooltipClassName,
!props.label && 'hidden'
)}
>
<p className={props.labelClassName}>{props.label}</p>
<div className={props.labelClassName}>{props.label}</div>
{props.keybinds && (
<div className="flex items-center justify-center gap-1">
{separateKeybinds(props.keybinds)?.map((k, _) => (
<kbd
key={k.toString()}
className={
'h-4.5 flex items-center justify-center rounded-md border border-app-selected bg-app-selected/50 px-1.5 py-0.5 text-[10px] text-ink'
}
>
<Kbd key={k.toString()}>
<p>{k}</p>
</kbd>
</Kbd>
))}
</div>
)}

View file

@ -1,26 +1,27 @@
export { cva, cx } from 'class-variance-authority';
export * from './Button';
export * from './CheckBox';
export { ContextMenu, useContextMenuContext, ContextMenuDivItem } from './ContextMenu';
export { DropdownMenu, useDropdownMenuContext } from './DropdownMenu';
export * from './CircularProgress';
export { ContextMenu, ContextMenuDivItem, useContextMenuContext } from './ContextMenu';
export * from './Dialog';
export * from './Divider';
export * as Dropdown from './Dropdown';
export { DropdownMenu, useDropdownMenuContext } from './DropdownMenu';
export * from './Input';
export * from './Layout';
export * from './Loader';
export * from './Popover';
export * from './ProgressBar';
export * as RadioGroup from './RadioGroup';
export * from './Resizable';
export * from './Select';
export * from './Shortcut';
export * from './Slider';
export * from './Switch';
export * as Tabs from './Tabs';
export * as RadioGroup from './RadioGroup';
export * from './Toast';
export * from './Tooltip';
export * from './Typography';
export * from './forms';
export * from './utils';
export * from './Tooltip';
export * from './Slider';
export * from './Divider';
export * from './CircularProgress';
export * from './Shortcut';
export * from './ProgressBar';
export * from './keys';
export * from './Toast';
export * from './utils';

View file

@ -1062,6 +1062,9 @@ importers:
react-loading-icons:
specifier: ^1.1.0
version: 1.1.0
react-resizable-layout:
specifier: ^0.7.2
version: 0.7.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-router-dom:
specifier: '=6.20.1'
version: 6.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@ -11289,6 +11292,12 @@ packages:
'@types/react':
optional: true
react-resizable-layout@0.7.2:
resolution: {integrity: sha512-GrVzAecB6+osdAx5PPP3G8R9n7A2uDd3NL+PyrHWNRaVBivZmW/o0+yFjQdS5Bo016A2fIP11fAhefsknWN4aw==}
peerDependencies:
react: '>=17.0.0'
react-dom: '>=17.0.0'
react-router-dom@6.20.1:
resolution: {integrity: sha512-npzfPWcxfQN35psS7rJgi/EW0Gx6EsNjfdJSAk73U/HqMEJZ2k/8puxfwHFgDQhBGmS3+sjnGbMdMSV45axPQw==}
engines: {node: '>=14.0.0'}
@ -26439,6 +26448,11 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.67
react-resizable-layout@0.7.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-router-dom@6.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
'@remix-run/router': 1.13.1(patch_hash=wdk5klbkacqsve2yzdckjvtjz4)