mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-23 01:49:13 +01:00
gRPC Support (#20)
This commit is contained in:
@@ -8,6 +8,7 @@ import { Icon } from './Icon';
|
||||
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color'> & {
|
||||
innerClassName?: string;
|
||||
color?: 'custom' | 'default' | 'gray' | 'primary' | 'secondary' | 'warning' | 'danger';
|
||||
variant?: 'border' | 'solid';
|
||||
isLoading?: boolean;
|
||||
size?: 'sm' | 'md' | 'xs';
|
||||
justify?: 'start' | 'center';
|
||||
@@ -27,10 +28,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
innerClassName,
|
||||
children,
|
||||
forDropdown,
|
||||
color,
|
||||
color = 'default',
|
||||
type = 'button',
|
||||
justify = 'center',
|
||||
size = 'md',
|
||||
variant = 'solid',
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
disabled,
|
||||
@@ -53,24 +55,45 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
'flex-shrink-0 flex items-center',
|
||||
'focus-visible-or-class:ring rounded-md',
|
||||
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
|
||||
color === 'custom' && 'ring-blue-500/50',
|
||||
color === 'default' &&
|
||||
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-1000 ring-blue-500/50',
|
||||
color === 'gray' &&
|
||||
'text-gray-800 bg-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50',
|
||||
color === 'primary' && 'bg-blue-400 text-white enabled:hocus:bg-blue-500 ring-blue-500/50',
|
||||
color === 'secondary' &&
|
||||
'bg-violet-400 text-white enabled:hocus:bg-violet-500 ring-violet-500/50',
|
||||
color === 'warning' &&
|
||||
'bg-orange-400 text-white enabled:hocus:bg-orange-500 ring-orange-500/50',
|
||||
color === 'danger' && 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
|
||||
justify === 'start' && 'justify-start',
|
||||
justify === 'center' && 'justify-center',
|
||||
size === 'md' && 'h-md px-3',
|
||||
size === 'sm' && 'h-sm px-2.5 text-sm',
|
||||
size === 'xs' && 'h-xs px-2 text-sm',
|
||||
// Solids
|
||||
variant === 'solid' && color === 'custom' && 'ring-blue-500/50',
|
||||
variant === 'solid' &&
|
||||
color === 'default' &&
|
||||
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-800 ring-blue-500/50',
|
||||
variant === 'solid' &&
|
||||
color === 'gray' &&
|
||||
'text-gray-800 bg-highlight enabled:hocus:text-gray-1000 ring-gray-400',
|
||||
variant === 'solid' && color === 'primary' && 'bg-blue-400 text-white ring-blue-700',
|
||||
variant === 'solid' && color === 'secondary' && 'bg-violet-400 text-white ring-violet-700',
|
||||
variant === 'solid' && color === 'warning' && 'bg-orange-400 text-white ring-orange-700',
|
||||
variant === 'solid' && color === 'danger' && 'bg-red-400 text-white ring-red-700',
|
||||
// Borders
|
||||
variant === 'border' && 'border',
|
||||
variant === 'border' &&
|
||||
color === 'default' &&
|
||||
'border-highlight text-gray-700 enabled:hocus:border-focus enabled:hocus:text-gray-800 ring-blue-500/50',
|
||||
variant === 'border' &&
|
||||
color === 'gray' &&
|
||||
'border-gray-500/70 text-gray-700 enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-800 ring-blue-500/50',
|
||||
variant === 'border' &&
|
||||
color === 'primary' &&
|
||||
'border-blue-500/70 text-blue-700 enabled:hocus:border-blue-500 ring-blue-500/50',
|
||||
variant === 'border' &&
|
||||
color === 'secondary' &&
|
||||
'border-violet-500/70 text-violet-700 enabled:hocus:border-violet-500 ring-violet-500/50',
|
||||
variant === 'border' &&
|
||||
color === 'warning' &&
|
||||
'border-orange-500/70 text-orange-700 enabled:hocus:border-orange-500 ring-orange-500/50',
|
||||
variant === 'border' &&
|
||||
color === 'danger' &&
|
||||
'border-red-500/70 text-red-700 enabled:hocus:border-red-500 ring-red-500/50',
|
||||
),
|
||||
[className, disabled, color, justify, size],
|
||||
[className, disabled, justify, size, variant, color],
|
||||
);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -100,7 +123,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
) : null}
|
||||
<div
|
||||
className={classNames(
|
||||
'max-w-[15em] truncate w-full',
|
||||
'truncate w-full',
|
||||
justify === 'start' ? 'text-left' : 'text-center',
|
||||
innerClassName,
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function CountBadge({ count, className }: Props) {
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
className,
|
||||
'opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
|
||||
'opacity-70 border border-highlight text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
|
||||
@@ -399,7 +399,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
||||
{items.map((item, i) => {
|
||||
if (item.type === 'separator') {
|
||||
return (
|
||||
<Separator key={i} className="ml-2 my-1.5">
|
||||
<Separator key={i} className={classNames('my-1.5', item.label && 'ml-2')}>
|
||||
{item.label}
|
||||
</Separator>
|
||||
);
|
||||
@@ -473,7 +473,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
|
||||
className={classNames(
|
||||
className,
|
||||
'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap',
|
||||
'focus:bg-highlight focus:text-gray-900 rounded',
|
||||
'focus:bg-highlight focus:text-gray-800 rounded',
|
||||
item.variant === 'danger' && 'text-red-600',
|
||||
item.variant === 'notify' && 'text-pink-600',
|
||||
)}
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface EditorProps {
|
||||
className?: string;
|
||||
heightMode?: 'auto' | 'full';
|
||||
contentType?: string | null;
|
||||
forceUpdateKey?: string;
|
||||
forceUpdateKey?: string | number;
|
||||
autoFocus?: boolean;
|
||||
autoSelect?: boolean;
|
||||
defaultValue?: string | null;
|
||||
|
||||
@@ -10,7 +10,6 @@ import { json } from '@codemirror/lang-json';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import type { LanguageSupport } from '@codemirror/language';
|
||||
import {
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
HighlightStyle,
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
} from '@codemirror/view';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
|
||||
import { jsonSchema } from 'codemirror-json-schema';
|
||||
import type { Environment, Workspace } from '../../../lib/models';
|
||||
import type { EditorProps } from './index';
|
||||
import { text } from './text/extension';
|
||||
@@ -83,6 +83,7 @@ export const myHighlightStyle = HighlightStyle.define([
|
||||
// ]);
|
||||
|
||||
const syntaxExtensions: Record<string, LanguageSupport> = {
|
||||
'application/grpc': jsonSchema() as any, // TODO: Fix this
|
||||
'application/graphql': graphqlLanguageSupport(),
|
||||
'application/json': json(),
|
||||
'application/javascript': javascript(),
|
||||
@@ -119,7 +120,6 @@ export const baseExtensions = [
|
||||
history(),
|
||||
dropCursor(),
|
||||
drawSelection(),
|
||||
bracketMatching(),
|
||||
// TODO: Figure out how to debounce showing of autocomplete in a good way
|
||||
// debouncedAutocompletionDisplay({ millis: 1000 }),
|
||||
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FormattedError({ children }: Props) {
|
||||
console.log('ERROR', children);
|
||||
return (
|
||||
<pre
|
||||
className={classNames(
|
||||
|
||||
@@ -5,33 +5,43 @@ import { memo } from 'react';
|
||||
|
||||
const icons = {
|
||||
archive: lucide.ArchiveIcon,
|
||||
arrowBigDownDash: lucide.ArrowBigDownDashIcon,
|
||||
arrowBigUpDash: lucide.ArrowBigUpDashIcon,
|
||||
arrowDown: lucide.ArrowDownIcon,
|
||||
arrowDownToDot: lucide.ArrowDownToDotIcon,
|
||||
arrowUp: lucide.ArrowUpIcon,
|
||||
arrowUpDown: lucide.ArrowUpDownIcon,
|
||||
arrowUpFromDot: lucide.ArrowUpFromDotIcon,
|
||||
box: lucide.BoxIcon,
|
||||
cake: lucide.CakeIcon,
|
||||
chat: lucide.MessageSquare,
|
||||
check: lucide.CheckIcon,
|
||||
chevronDown: lucide.ChevronDownIcon,
|
||||
chevronRight: lucide.ChevronRightIcon,
|
||||
cookie: lucide.CookieIcon,
|
||||
code: lucide.CodeIcon,
|
||||
cookie: lucide.CookieIcon,
|
||||
copy: lucide.CopyIcon,
|
||||
download: lucide.DownloadIcon,
|
||||
folderInput: lucide.FolderInputIcon,
|
||||
folderOutput: lucide.FolderOutputIcon,
|
||||
externalLink: lucide.ExternalLinkIcon,
|
||||
eye: lucide.EyeIcon,
|
||||
eyeClosed: lucide.EyeOffIcon,
|
||||
filter: lucide.FilterIcon,
|
||||
flask: lucide.FlaskConicalIcon,
|
||||
folderInput: lucide.FolderInputIcon,
|
||||
folderOutput: lucide.FolderOutputIcon,
|
||||
gripVertical: lucide.GripVerticalIcon,
|
||||
info: lucide.InfoIcon,
|
||||
keyboard: lucide.KeyboardIcon,
|
||||
leftPanelHidden: lucide.PanelLeftOpenIcon,
|
||||
leftPanelVisible: lucide.PanelLeftCloseIcon,
|
||||
magicWand: lucide.Wand2Icon,
|
||||
moreVertical: lucide.MoreVerticalIcon,
|
||||
pencil: lucide.PencilIcon,
|
||||
plug: lucide.Plug,
|
||||
plus: lucide.PlusIcon,
|
||||
plusCircle: lucide.PlusCircleIcon,
|
||||
question: lucide.ShieldQuestionIcon,
|
||||
refresh: lucide.RefreshCwIcon,
|
||||
sendHorizontal: lucide.SendHorizonalIcon,
|
||||
settings2: lucide.Settings2Icon,
|
||||
settings: lucide.SettingsIcon,
|
||||
@@ -47,7 +57,7 @@ const icons = {
|
||||
export interface IconProps {
|
||||
icon: keyof typeof icons;
|
||||
className?: string;
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
spin?: boolean;
|
||||
}
|
||||
|
||||
@@ -57,7 +67,8 @@ export const Icon = memo(function Icon({ icon, spin, size = 'md', className }: I
|
||||
<Component
|
||||
className={classNames(
|
||||
className,
|
||||
'text-inherit',
|
||||
'text-inherit flex-shrink-0',
|
||||
size === 'lg' && 'h-5 w-5',
|
||||
size === 'md' && 'h-4 w-4',
|
||||
size === 'sm' && 'h-3.5 w-3.5',
|
||||
size === 'xs' && 'h-3 w-3',
|
||||
|
||||
@@ -6,7 +6,8 @@ export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanEleme
|
||||
<code
|
||||
className={classNames(
|
||||
className,
|
||||
'font-mono text-sm bg-highlight border-0 border-gray-200 px-1.5 py-0.5 rounded text-gray-800 shadow-inner',
|
||||
'font-mono text-xs bg-highlight border-0 border-gray-200/30',
|
||||
'px-1.5 py-0.5 rounded text-gray-800 shadow-inner',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
122
src-web/components/core/JsonAttributeTree.tsx
Normal file
122
src-web/components/core/JsonAttributeTree.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
depth?: number;
|
||||
attrValue: any;
|
||||
attrKey?: string | number;
|
||||
attrKeyJsonPath?: string;
|
||||
}
|
||||
|
||||
export const JsonAttributeTree = ({ depth = 0, attrKey, attrValue, attrKeyJsonPath }: Props) => {
|
||||
attrKeyJsonPath = attrKeyJsonPath ?? `${attrKey}`;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const toggleExpanded = () => setIsExpanded((v) => !v);
|
||||
|
||||
const { isExpandable, children, label, labelClassName } = useMemo<{
|
||||
isExpandable: boolean;
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
labelClassName?: string;
|
||||
}>(() => {
|
||||
const jsonType = Object.prototype.toString.call(attrValue);
|
||||
if (jsonType === '[object Object]') {
|
||||
return {
|
||||
children: isExpanded
|
||||
? Object.keys(attrValue)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.flatMap((k) => (
|
||||
<JsonAttributeTree
|
||||
depth={depth + 1}
|
||||
attrValue={attrValue[k]}
|
||||
attrKey={k}
|
||||
attrKeyJsonPath={joinObjectKey(attrKeyJsonPath, k)}
|
||||
/>
|
||||
))
|
||||
: null,
|
||||
isExpandable: true,
|
||||
label: isExpanded ? '{ }' : `{⋯}`,
|
||||
labelClassName: 'text-gray-600',
|
||||
};
|
||||
} else if (jsonType === '[object Array]') {
|
||||
return {
|
||||
children: isExpanded
|
||||
? attrValue.flatMap((v: any, i: number) => (
|
||||
<JsonAttributeTree
|
||||
depth={depth + 1}
|
||||
attrValue={v}
|
||||
attrKey={i}
|
||||
attrKeyJsonPath={joinArrayKey(attrKeyJsonPath, i)}
|
||||
/>
|
||||
))
|
||||
: null,
|
||||
isExpandable: true,
|
||||
label: isExpanded ? '[ ]' : `[⋯]`,
|
||||
labelClassName: 'text-gray-600',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
children: null,
|
||||
isExpandable: false,
|
||||
label: jsonType === '[object String]' ? `"${attrValue}"` : `${attrValue}`,
|
||||
labelClassName: classNames(
|
||||
jsonType === '[object Boolean]' && 'text-pink-600',
|
||||
jsonType === '[object Number]' && 'text-blue-600',
|
||||
jsonType === '[object String]' && 'text-yellow-600',
|
||||
jsonType === '[object Null]' && 'text-red-600',
|
||||
),
|
||||
};
|
||||
}
|
||||
}, [attrValue, attrKeyJsonPath, isExpanded, depth]);
|
||||
|
||||
const labelEl = (
|
||||
<span className={classNames(labelClassName, 'select-text group-hover:text-gray-800')}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div className={classNames(/*depth === 0 && '-ml-4',*/ 'font-mono text-2xs')}>
|
||||
<div className="flex items-center">
|
||||
{isExpandable ? (
|
||||
<button className="group relative flex items-center pl-4 w-full" onClick={toggleExpanded}>
|
||||
<Icon
|
||||
size="xs"
|
||||
icon="chevronRight"
|
||||
className={classNames(
|
||||
'left-0 absolute transition-transform text-gray-600 flex items-center',
|
||||
'group-hover:text-gray-900',
|
||||
isExpanded ? 'rotate-90' : '',
|
||||
)}
|
||||
/>
|
||||
<span className="text-violet-600 mr-1.5 whitespace-nowrap">
|
||||
{attrKey === undefined ? '$' : attrKey}:
|
||||
</span>
|
||||
{labelEl}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-violet-600 mr-1.5 pl-4 whitespace-nowrap select-text">
|
||||
{attrKey}:
|
||||
</span>
|
||||
{labelEl}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{children && <div className="ml-4 whitespace-nowrap">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function joinObjectKey(baseKey: string | undefined, key: string): string {
|
||||
const quotedKey = key.match(/^[a-z0-9_]+$/i) ? key : `\`${key}\``;
|
||||
|
||||
if (baseKey == null) return quotedKey;
|
||||
else return `${baseKey}.${quotedKey}`;
|
||||
}
|
||||
|
||||
function joinArrayKey(baseKey: string | undefined, index: number): string {
|
||||
return `${baseKey ?? ''}[${index}]`;
|
||||
}
|
||||
35
src-web/components/core/Link.tsx
Normal file
35
src-web/components/core/Link.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLAnchorElement> {
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function Link({ href, children, className, ...other }: Props) {
|
||||
const isExternal = href.match(/^https?:\/\//);
|
||||
|
||||
className = classNames(className, 'relative underline hover:text-violet-600');
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={classNames(className, 'pr-4')}
|
||||
{...other}
|
||||
>
|
||||
<span className="underline">{children}</span>
|
||||
<Icon className="inline absolute right-0.5 top-0.5" size="xs" icon="externalLink" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RouterLink to={href} className={className} {...other}>
|
||||
{children}
|
||||
</RouterLink>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,11 @@ interface Props<T extends string> {
|
||||
labelPosition?: 'top' | 'left';
|
||||
labelClassName?: string;
|
||||
hideLabel?: boolean;
|
||||
value: string;
|
||||
options: Record<T, string>;
|
||||
value: T;
|
||||
options: { label: string; value: T }[];
|
||||
onChange: (value: T) => void;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Select<T extends string>({
|
||||
@@ -21,12 +22,14 @@ export function Select<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
size = 'md',
|
||||
}: Props<T>) {
|
||||
const id = `input-${name}`;
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
'w-full',
|
||||
'pointer-events-auto', // Just in case we're placing in disabled parent
|
||||
labelPosition === 'left' && 'flex items-center gap-2',
|
||||
@@ -48,7 +51,7 @@ export function Select<T extends string>({
|
||||
style={selectBackgroundStyles}
|
||||
onChange={(e) => onChange(e.target.value as T)}
|
||||
className={classNames(
|
||||
'font-mono text-xs border w-full px-2 outline-none bg-transparent',
|
||||
'font-mono text-xs border w-full outline-none bg-transparent pl-2 pr-7',
|
||||
'border-highlight focus:border-focus',
|
||||
size === 'xs' && 'h-xs',
|
||||
size === 'sm' && 'h-sm',
|
||||
@@ -56,8 +59,8 @@ export function Select<T extends string>({
|
||||
size === 'lg' && 'h-lg',
|
||||
)}
|
||||
>
|
||||
{Object.entries<string>(options).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{options.map(({ label, value }) => (
|
||||
<option key={label} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
@@ -68,7 +71,7 @@ export function Select<T extends string>({
|
||||
|
||||
const selectBackgroundStyles = {
|
||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
|
||||
backgroundPosition: 'right 0.5rem center',
|
||||
backgroundPosition: 'right 0.3rem center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: '1.5em 1.5em',
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
variant?: 'primary' | 'secondary';
|
||||
className?: string;
|
||||
children?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
|
||||
169
src-web/components/core/SplitLayout.tsx
Normal file
169
src-web/components/core/SplitLayout.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import useResizeObserver from '@react-hook/resize-observer';
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode } from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { useActiveRequestId } from '../../hooks/useActiveRequestId';
|
||||
import { useActiveWorkspaceId } from '../../hooks/useActiveWorkspaceId';
|
||||
import { clamp } from '../../lib/clamp';
|
||||
import { ResizeHandle } from '../ResizeHandle';
|
||||
import { HotKeyList } from './HotKeyList';
|
||||
|
||||
interface SlotProps {
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
firstSlot: (props: SlotProps) => ReactNode;
|
||||
secondSlot: null | ((props: SlotProps) => ReactNode);
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
defaultRatio?: number;
|
||||
minHeightPx?: number;
|
||||
minWidthPx?: number;
|
||||
forceVertical?: boolean;
|
||||
}
|
||||
|
||||
const areaL = { gridArea: 'left' };
|
||||
const areaR = { gridArea: 'right' };
|
||||
const areaD = { gridArea: 'drag' };
|
||||
|
||||
const STACK_VERTICAL_WIDTH = 700;
|
||||
|
||||
export function SplitLayout({
|
||||
style,
|
||||
firstSlot,
|
||||
secondSlot,
|
||||
className,
|
||||
name,
|
||||
forceVertical,
|
||||
defaultRatio = 0.5,
|
||||
minHeightPx = 10,
|
||||
minWidthPx = 10,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [vertical, setVertical] = useState<boolean>(false);
|
||||
const [widthRaw, setWidth] = useLocalStorage<number>(`${name}_width::${useActiveWorkspaceId()}`);
|
||||
const [heightRaw, setHeight] = useLocalStorage<number>(
|
||||
`${name}_height::${useActiveWorkspaceId()}`,
|
||||
);
|
||||
const width = widthRaw ?? defaultRatio;
|
||||
let height = heightRaw ?? defaultRatio;
|
||||
const [isResizing, setIsResizing] = useState<boolean>(false);
|
||||
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (!secondSlot) {
|
||||
height = 0;
|
||||
minHeightPx = 0;
|
||||
}
|
||||
|
||||
useResizeObserver(containerRef.current, ({ contentRect }) => {
|
||||
setVertical(contentRect.width < STACK_VERTICAL_WIDTH);
|
||||
});
|
||||
|
||||
const styles = useMemo<CSSProperties>(() => {
|
||||
return {
|
||||
...style,
|
||||
gridTemplate:
|
||||
forceVertical || vertical
|
||||
? `
|
||||
' ${areaL.gridArea}' minmax(0,${1 - height}fr)
|
||||
' ${areaD.gridArea}' 0
|
||||
' ${areaR.gridArea}' minmax(${minHeightPx}px,${height}fr)
|
||||
/ 1fr
|
||||
`
|
||||
: `
|
||||
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)
|
||||
/ ${1 - width}fr 0 ${width}fr
|
||||
`,
|
||||
};
|
||||
}, [style, vertical, height, minHeightPx, width]);
|
||||
|
||||
const unsub = () => {
|
||||
if (moveState.current !== null) {
|
||||
document.documentElement.removeEventListener('mousemove', moveState.current.move);
|
||||
document.documentElement.removeEventListener('mouseup', moveState.current.up);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = useCallback(
|
||||
() => (vertical ? setHeight(defaultRatio) : setWidth(defaultRatio)),
|
||||
[vertical, setHeight, defaultRatio, setWidth],
|
||||
);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (containerRef.current === null) return;
|
||||
unsub();
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
|
||||
const mouseStartX = e.clientX;
|
||||
const mouseStartY = e.clientY;
|
||||
const startWidth = containerRect.width * width;
|
||||
const startHeight = containerRect.height * height;
|
||||
|
||||
moveState.current = {
|
||||
move: (e: MouseEvent) => {
|
||||
e.preventDefault(); // Prevent text selection and things
|
||||
if (vertical) {
|
||||
const maxHeightPx = containerRect.height - minHeightPx;
|
||||
const newHeightPx = clamp(
|
||||
startHeight - (e.clientY - mouseStartY),
|
||||
minHeightPx,
|
||||
maxHeightPx,
|
||||
);
|
||||
setHeight(newHeightPx / containerRect.height);
|
||||
} else {
|
||||
const maxWidthPx = containerRect.width - minWidthPx;
|
||||
const newWidthPx = clamp(
|
||||
startWidth - (e.clientX - mouseStartX),
|
||||
minWidthPx,
|
||||
maxWidthPx,
|
||||
);
|
||||
setWidth(newWidthPx / containerRect.width);
|
||||
}
|
||||
},
|
||||
up: (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
unsub();
|
||||
setIsResizing(false);
|
||||
},
|
||||
};
|
||||
document.documentElement.addEventListener('mousemove', moveState.current.move);
|
||||
document.documentElement.addEventListener('mouseup', moveState.current.up);
|
||||
setIsResizing(true);
|
||||
},
|
||||
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
|
||||
);
|
||||
|
||||
const activeRequestId = useActiveRequestId();
|
||||
if (activeRequestId === null) {
|
||||
return <HotKeyList hotkeys={['http_request.create', 'sidebar.toggle']} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={classNames(className, 'grid w-full h-full')} style={styles}>
|
||||
{firstSlot({ style: areaL, orientation: vertical ? 'vertical' : 'horizontal' })}
|
||||
{secondSlot && (
|
||||
<>
|
||||
<ResizeHandle
|
||||
style={areaD}
|
||||
isResizing={isResizing}
|
||||
barClassName={'bg-red-300'}
|
||||
className={classNames(vertical ? 'translate-y-0.5' : 'translate-x-0.5')}
|
||||
onResizeStart={handleResizeStart}
|
||||
onReset={handleReset}
|
||||
side={vertical ? 'top' : 'left'}
|
||||
justify="center"
|
||||
/>
|
||||
{secondSlot({ style: areaR, orientation: vertical ? 'vertical' : 'horizontal' })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const gapClasses = {
|
||||
0: 'gap-0',
|
||||
0.5: 'gap-0.5',
|
||||
1: 'gap-1',
|
||||
1.5: 'gap-1.5',
|
||||
2: 'gap-2',
|
||||
3: 'gap-3',
|
||||
4: 'gap-4',
|
||||
@@ -56,7 +57,7 @@ export const VStack = forwardRef(function VStack(
|
||||
type BaseStackProps = HTMLAttributes<HTMLElement> & {
|
||||
as?: ComponentType | 'ul' | 'label' | 'form';
|
||||
space?: keyof typeof gapClasses;
|
||||
alignItems?: 'start' | 'center' | 'stretch';
|
||||
alignItems?: 'start' | 'center' | 'stretch' | 'end';
|
||||
justifyContent?: 'start' | 'center' | 'end' | 'between';
|
||||
};
|
||||
|
||||
@@ -75,6 +76,7 @@ const BaseStack = forwardRef(function BaseStack(
|
||||
alignItems === 'center' && 'items-center',
|
||||
alignItems === 'start' && 'items-start',
|
||||
alignItems === 'stretch' && 'items-stretch',
|
||||
alignItems === 'end' && 'items-end',
|
||||
justifyContent === 'start' && 'justify-start',
|
||||
justifyContent === 'center' && 'justify-center',
|
||||
justifyContent === 'end' && 'justify-end',
|
||||
|
||||
Reference in New Issue
Block a user