mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-21 08:59:22 +01:00
Move stuff around
This commit is contained in:
93
src-web/components/core/Button.tsx
Normal file
93
src-web/components/core/Button.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import classnames from 'classnames';
|
||||
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
const colorStyles = {
|
||||
custom: '',
|
||||
default: 'text-gray-700 enabled:hover:bg-gray-700/10 enabled:hover:text-gray-1000',
|
||||
gray: 'text-gray-800 bg-gray-100 enabled:hover:bg-gray-500/20 enabled:hover:text-gray-1000',
|
||||
primary: 'bg-blue-400 text-white hover:bg-blue-500',
|
||||
secondary: 'bg-violet-400 text-white hover:bg-violet-500',
|
||||
warning: 'bg-orange-400 text-white hover:bg-orange-500',
|
||||
danger: 'bg-red-400 text-white hover:bg-red-500',
|
||||
};
|
||||
|
||||
export type ButtonProps = {
|
||||
to?: string;
|
||||
color?: keyof typeof colorStyles;
|
||||
size?: 'sm' | 'md';
|
||||
justify?: 'start' | 'center';
|
||||
type?: 'button' | 'submit';
|
||||
onClick?: (event: MouseEvent<HTMLElement>) => void;
|
||||
onDoubleClick?: (event: MouseEvent<HTMLElement>) => void;
|
||||
contentEditable?: boolean;
|
||||
onKeyDown?: (event: KeyboardEvent<HTMLElement>) => void;
|
||||
forDropdown?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const Button = forwardRef<any, ButtonProps>(function Button(
|
||||
{
|
||||
to,
|
||||
className,
|
||||
children,
|
||||
forDropdown,
|
||||
color,
|
||||
justify = 'center',
|
||||
size = 'md',
|
||||
...props
|
||||
}: ButtonProps,
|
||||
ref,
|
||||
) {
|
||||
if (typeof to === 'string') {
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
to={to}
|
||||
className={classnames(
|
||||
className,
|
||||
'outline-none',
|
||||
'border border-transparent focus-visible:border-blue-300',
|
||||
'rounded-md flex items-center',
|
||||
colorStyles[color || 'default'],
|
||||
justify === 'start' && 'justify-start',
|
||||
justify === 'center' && 'justify-center',
|
||||
size === 'md' && 'h-9 px-3',
|
||||
size === 'sm' && 'h-7 px-2.5 text-sm',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={classnames(
|
||||
className,
|
||||
'outline-none',
|
||||
'border border-transparent focus-visible:border-blue-300',
|
||||
'rounded-md flex items-center',
|
||||
colorStyles[color || 'default'],
|
||||
justify === 'start' && 'justify-start',
|
||||
justify === 'center' && 'justify-center',
|
||||
size === 'md' && 'h-9 px-3',
|
||||
size === 'sm' && 'h-7 px-2.5 text-sm',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
13
src-web/components/core/ButtonLink.tsx
Normal file
13
src-web/components/core/ButtonLink.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ButtonProps } from './Button';
|
||||
import { Button } from './Button';
|
||||
|
||||
type Props = ButtonProps & {
|
||||
to: string;
|
||||
};
|
||||
|
||||
export function ButtonLink({ to, className, ...buttonProps }: Props) {
|
||||
return (
|
||||
<Button to={to} className={classnames(className, 'w-full')} tabIndex={-1} {...buttonProps} />
|
||||
);
|
||||
}
|
||||
58
src-web/components/core/Dialog.tsx
Normal file
58
src-web/components/core/Dialog.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as D from '@radix-ui/react-dialog';
|
||||
import classnames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ReactNode } from 'react';
|
||||
import { IconButton } from './IconButton';
|
||||
import { HStack, VStack } from './Stacks';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
children,
|
||||
className,
|
||||
wide,
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
}: Props) {
|
||||
return (
|
||||
<D.Root open={open} onOpenChange={onOpenChange}>
|
||||
<D.Portal container={document.querySelector<HTMLElement>('#radix-portal')}>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
<D.Overlay className="fixed inset-0 bg-gray-600/60 dark:bg-black/50" />
|
||||
<D.Content>
|
||||
<div
|
||||
className={classnames(
|
||||
className,
|
||||
'absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] bg-gray-100',
|
||||
'w-[20rem] max-h-[80vh] p-5 rounded-lg overflow-auto',
|
||||
'dark:border border-gray-200 shadow-md shadow-black/10',
|
||||
wide && 'w-[80vw] max-w-[50rem]',
|
||||
)}
|
||||
>
|
||||
<D.Close asChild className="ml-auto absolute right-1 top-1">
|
||||
<IconButton aria-label="Close" icon="x" size="sm" />
|
||||
</D.Close>
|
||||
<VStack space={3}>
|
||||
<HStack alignItems="center" className="pb-3">
|
||||
<D.Title className="text-xl font-semibold">{title}</D.Title>
|
||||
</HStack>
|
||||
{description && <D.Description>{description}</D.Description>}
|
||||
<div>{children}</div>
|
||||
</VStack>
|
||||
</div>
|
||||
</D.Content>
|
||||
</motion.div>
|
||||
</D.Portal>
|
||||
</D.Root>
|
||||
);
|
||||
}
|
||||
23
src-web/components/core/Divider.tsx
Normal file
23
src-web/components/core/Divider.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as Separator from '@radix-ui/react-separator';
|
||||
import classnames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
decorative?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Divider({ className, orientation = 'horizontal', decorative }: Props) {
|
||||
return (
|
||||
<Separator.Root
|
||||
className={classnames(
|
||||
className,
|
||||
'bg-gray-50',
|
||||
orientation === 'horizontal' && 'w-full h-[1px]',
|
||||
orientation === 'vertical' && 'h-full w-[1px]',
|
||||
)}
|
||||
orientation={orientation}
|
||||
decorative={decorative}
|
||||
/>
|
||||
);
|
||||
}
|
||||
306
src-web/components/core/Dropdown.tsx
Normal file
306
src-web/components/core/Dropdown.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import * as D from '@radix-ui/react-dropdown-menu';
|
||||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import classnames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ReactNode, ForwardedRef } from 'react';
|
||||
import { forwardRef, useImperativeHandle, useLayoutEffect, useState } from 'react';
|
||||
|
||||
interface DropdownMenuRadioProps {
|
||||
children: ReactNode;
|
||||
onValueChange: ((v: { label: string; value: string }) => void) | null;
|
||||
value: string;
|
||||
label?: string;
|
||||
items: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function DropdownMenuRadio({
|
||||
children,
|
||||
items,
|
||||
onValueChange,
|
||||
label,
|
||||
value,
|
||||
}: DropdownMenuRadioProps) {
|
||||
const handleChange = (value: string) => {
|
||||
const item = items.find((item) => item.value === value);
|
||||
if (item && onValueChange) {
|
||||
onValueChange(item);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<D.Root>
|
||||
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
{label && <DropdownMenuLabel>{label}</DropdownMenuLabel>}
|
||||
<D.DropdownMenuRadioGroup onValueChange={handleChange} value={value}>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuRadioItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</D.DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</D.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DropdownProps {
|
||||
children: ReactNode;
|
||||
items: (
|
||||
| {
|
||||
label: string;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
leftSlot?: ReactNode;
|
||||
}
|
||||
| '-----'
|
||||
)[];
|
||||
}
|
||||
|
||||
export function Dropdown({ children, items }: DropdownProps) {
|
||||
return (
|
||||
<D.Root>
|
||||
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
{items.map((item, i) => {
|
||||
if (item === '-----') {
|
||||
return <DropdownMenuSeparator key={i} />;
|
||||
} else {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={i}
|
||||
onSelect={() => item.onSelect?.()}
|
||||
disabled={item.disabled}
|
||||
leftSlot={item.leftSlot}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</D.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface DropdownMenuPortalProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ children }: DropdownMenuPortalProps) {
|
||||
const container = document.querySelector<Element>('#radix-portal');
|
||||
if (container === null) return null;
|
||||
return (
|
||||
<D.Portal>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
{children}
|
||||
</motion.div>
|
||||
</D.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const DropdownMenuContent = forwardRef<HTMLDivElement, D.DropdownMenuContentProps>(
|
||||
function DropdownMenuContent(
|
||||
{ className, children, ...props }: D.DropdownMenuContentProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const [styles, setStyles] = useState<{ maxHeight: number }>();
|
||||
const [divRef, setDivRef] = useState<HTMLDivElement | null>(null);
|
||||
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(ref, () => divRef);
|
||||
|
||||
const initDivRef = (ref: HTMLDivElement | null) => {
|
||||
setDivRef(ref);
|
||||
};
|
||||
|
||||
// Calculate the max height so we can scroll
|
||||
useLayoutEffect(() => {
|
||||
if (divRef === null) return;
|
||||
// Needs to be in a setTimeout because the ref is not positioned yet
|
||||
// TODO: Make this better?
|
||||
setTimeout(() => {
|
||||
const windowBox = document.documentElement.getBoundingClientRect();
|
||||
const menuBox = divRef.getBoundingClientRect();
|
||||
const styles = { maxHeight: windowBox.height - menuBox.top - 5 - 45 };
|
||||
setStyles(styles);
|
||||
});
|
||||
}, [divRef]);
|
||||
|
||||
return (
|
||||
<D.Content
|
||||
ref={initDivRef}
|
||||
align="start"
|
||||
className={classnames(
|
||||
className,
|
||||
'bg-gray-50 rounded-md shadow-lg p-1.5 border border-gray-200',
|
||||
'overflow-auto m-1',
|
||||
)}
|
||||
style={styles}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</D.Content>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type DropdownMenuItemProps = D.DropdownMenuItemProps & ItemInnerProps;
|
||||
|
||||
function DropdownMenuItem({
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: DropdownMenuItemProps) {
|
||||
return (
|
||||
<D.Item
|
||||
asChild
|
||||
disabled={disabled}
|
||||
className={classnames(className, disabled && 'opacity-30')}
|
||||
{...props}
|
||||
>
|
||||
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
|
||||
{children}
|
||||
</ItemInner>
|
||||
</D.Item>
|
||||
);
|
||||
}
|
||||
|
||||
// type DropdownMenuCheckboxItemProps = DropdownMenu.DropdownMenuCheckboxItemProps & ItemInnerProps;
|
||||
//
|
||||
// function DropdownMenuCheckboxItem({
|
||||
// leftSlot,
|
||||
// rightSlot,
|
||||
// children,
|
||||
// ...props
|
||||
// }: DropdownMenuCheckboxItemProps) {
|
||||
// return (
|
||||
// <DropdownMenu.CheckboxItem asChild {...props}>
|
||||
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
|
||||
// {children}
|
||||
// </ItemInner>
|
||||
// </DropdownMenu.CheckboxItem>
|
||||
// );
|
||||
// }
|
||||
|
||||
// type DropdownMenuSubTriggerProps = DropdownMenu.DropdownMenuSubTriggerProps & ItemInnerProps;
|
||||
//
|
||||
// function DropdownMenuSubTrigger({
|
||||
// leftSlot,
|
||||
// rightSlot,
|
||||
// children,
|
||||
// ...props
|
||||
// }: DropdownMenuSubTriggerProps) {
|
||||
// return (
|
||||
// <DropdownMenu.SubTrigger asChild {...props}>
|
||||
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
|
||||
// {children}
|
||||
// </ItemInner>
|
||||
// </DropdownMenu.SubTrigger>
|
||||
// );
|
||||
// }
|
||||
|
||||
type DropdownMenuRadioItemProps = Omit<D.DropdownMenuRadioItemProps & ItemInnerProps, 'leftSlot'>;
|
||||
|
||||
function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRadioItemProps) {
|
||||
return (
|
||||
<D.RadioItem asChild {...props}>
|
||||
<ItemInner
|
||||
leftSlot={
|
||||
<D.ItemIndicator>
|
||||
<CheckIcon />
|
||||
</D.ItemIndicator>
|
||||
}
|
||||
rightSlot={rightSlot}
|
||||
>
|
||||
{children}
|
||||
</ItemInner>
|
||||
</D.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
// const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuSubContentProps>(
|
||||
// function DropdownMenuSubContent(
|
||||
// { className, ...props }: DropdownMenu.DropdownMenuSubContentProps,
|
||||
// ref,
|
||||
// ) {
|
||||
// return (
|
||||
// <DropdownMenu.SubContent
|
||||
// ref={ref}
|
||||
// alignOffset={0}
|
||||
// sideOffset={4}
|
||||
// className={classnames(className, dropdownMenuClasses)}
|
||||
// {...props}
|
||||
// />
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
|
||||
function DropdownMenuLabel({ className, children, ...props }: D.DropdownMenuLabelProps) {
|
||||
return (
|
||||
<D.Label asChild {...props}>
|
||||
<ItemInner noHover className={classnames(className, 'opacity-50 uppercase text-sm')}>
|
||||
{children}
|
||||
</ItemInner>
|
||||
</D.Label>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({ className, ...props }: D.DropdownMenuSeparatorProps) {
|
||||
return (
|
||||
<D.Separator
|
||||
className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuTriggerProps = D.DropdownMenuTriggerProps & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function DropdownMenuTrigger({ children, className, ...props }: DropdownMenuTriggerProps) {
|
||||
return (
|
||||
<D.Trigger asChild className={classnames(className)} {...props}>
|
||||
{children}
|
||||
</D.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemInnerProps {
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
children: ReactNode;
|
||||
noHover?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ItemInner = forwardRef<HTMLDivElement, ItemInnerProps>(function ItemInner(
|
||||
{ leftSlot, rightSlot, children, className, noHover, ...props }: ItemInnerProps,
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classnames(
|
||||
className,
|
||||
'outline-none px-2 py-1.5 flex items-center text-sm text-gray-700 whitespace-nowrap pr-4',
|
||||
!noHover && 'focus:bg-gray-50 focus:text-gray-900 rounded',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{leftSlot && <div className="w-6">{leftSlot}</div>}
|
||||
<div>{children}</div>
|
||||
{rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
166
src-web/components/core/Editor/Editor.css
Normal file
166
src-web/components/core/Editor/Editor.css
Normal file
@@ -0,0 +1,166 @@
|
||||
.cm-wrapper {
|
||||
@apply h-full overflow-hidden;
|
||||
|
||||
.cm-editor {
|
||||
@apply w-full block text-base;
|
||||
|
||||
* {
|
||||
@apply cursor-text;
|
||||
}
|
||||
|
||||
&.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply text-gray-900 pl-1 pr-1.5;
|
||||
}
|
||||
|
||||
.cm-placeholder {
|
||||
@apply text-placeholder;
|
||||
}
|
||||
|
||||
/* Don't show selection on blurred input */
|
||||
.cm-selectionBackground {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
&.cm-focused .cm-selectionBackground {
|
||||
@apply bg-gray-400;
|
||||
}
|
||||
|
||||
/* Style gutters */
|
||||
.cm-gutters {
|
||||
@apply border-0 text-gray-500/60;
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply cursor-default;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder-widget {
|
||||
@apply text-[0.9em] text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
|
||||
|
||||
/* NOTE: Background and border are translucent so we can see text selection through it */
|
||||
@apply bg-gray-300/40 border border-gray-300 border-opacity-40 hover:border-opacity-80;
|
||||
|
||||
/* Bring above on hover */
|
||||
@apply hover:z-10 relative;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-singleline {
|
||||
.cm-editor {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono flex text-[0.8rem];
|
||||
align-items: center !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply px-0;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-multiline {
|
||||
&.cm-full-height {
|
||||
@apply relative;
|
||||
|
||||
.cm-editor {
|
||||
@apply inset-0 absolute;
|
||||
position: absolute !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono text-[0.75rem];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor .cm-gutterElement {
|
||||
transition: color var(--transition-duration);
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon {
|
||||
@apply pt-[0.3em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon::after {
|
||||
@apply block w-1.5 h-1.5 border-transparent -rotate-45
|
||||
border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon[data-open] {
|
||||
@apply pt-[0.4em] pl-[0.3em];
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon[data-open]::after {
|
||||
@apply rotate-[-135deg];
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon:hover {
|
||||
@apply text-gray-900 bg-gray-300/50;
|
||||
}
|
||||
|
||||
.cm-editor .cm-foldPlaceholder {
|
||||
@apply px-2 border border-gray-400/50 bg-gray-300/50 cursor-default;
|
||||
@apply hover:text-gray-800 hover:border-gray-400;
|
||||
}
|
||||
|
||||
.cm-editor .cm-activeLineGutter {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
.cm-wrapper:not(.cm-readonly) .cm-editor {
|
||||
&.cm-focused .cm-activeLineGutter {
|
||||
@apply text-gray-800;
|
||||
}
|
||||
|
||||
.cm-cursor {
|
||||
@apply border-l-2 border-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-singleline .cm-editor {
|
||||
.cm-content {
|
||||
@apply h-full flex items-center;
|
||||
}
|
||||
}
|
||||
|
||||
/* NOTE: Extra selector required to override default styles */
|
||||
.cm-tooltip.cm-tooltip {
|
||||
@apply shadow-lg bg-gray-50 rounded overflow-hidden text-gray-900 border border-gray-200 z-50 pointer-events-auto;
|
||||
|
||||
* {
|
||||
@apply transition-none;
|
||||
}
|
||||
|
||||
&.cm-tooltip-autocomplete {
|
||||
& > ul {
|
||||
@apply p-1 max-h-[40vh];
|
||||
}
|
||||
|
||||
& > ul > li {
|
||||
@apply cursor-default px-2 rounded-sm text-gray-600 h-7 flex items-center;
|
||||
}
|
||||
|
||||
& > ul > li[aria-selected] {
|
||||
@apply bg-gray-100 text-gray-900;
|
||||
}
|
||||
|
||||
& > ul > li:hover {
|
||||
@apply text-gray-800;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply text-sm flex items-center pb-0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
188
src-web/components/core/Editor/Editor.tsx
Normal file
188
src-web/components/core/Editor/Editor.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
|
||||
import classnames from 'classnames';
|
||||
import { EditorView } from 'codemirror';
|
||||
import { formatSdl } from 'format-graphql';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useDebounce, useUnmount } from 'react-use';
|
||||
import { debounce } from '../../../lib/debounce';
|
||||
import { IconButton } from '../IconButton';
|
||||
import './Editor.css';
|
||||
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
|
||||
import { singleLineExt } from './singleLine';
|
||||
|
||||
export interface _EditorProps {
|
||||
id?: string;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
heightMode?: 'auto' | 'full';
|
||||
contentType?: string;
|
||||
autoFocus?: boolean;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
tooltipContainer?: HTMLElement;
|
||||
useTemplating?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
singleLine?: boolean;
|
||||
}
|
||||
|
||||
export function _Editor({
|
||||
readOnly,
|
||||
heightMode,
|
||||
contentType,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
useTemplating,
|
||||
defaultValue,
|
||||
onChange,
|
||||
className,
|
||||
singleLine,
|
||||
}: _EditorProps) {
|
||||
const cm = useRef<{ view: EditorView; langHolder: Compartment } | null>(null);
|
||||
|
||||
// Unmount the editor
|
||||
useUnmount(() => {
|
||||
cm.current?.view.destroy();
|
||||
cm.current = null;
|
||||
});
|
||||
|
||||
// Update language extension when contentType changes
|
||||
useEffect(() => {
|
||||
if (cm.current === null) return;
|
||||
const { view, langHolder } = cm.current;
|
||||
const ext = getLanguageExtension({ contentType, useTemplating });
|
||||
view.dispatch({ effects: langHolder.reconfigure(ext) });
|
||||
}, [contentType]);
|
||||
|
||||
// Initialize the editor
|
||||
const initDivRef = (el: HTMLDivElement | null) => {
|
||||
if (el === null || cm.current !== null) return;
|
||||
|
||||
try {
|
||||
const langHolder = new Compartment();
|
||||
const langExt = getLanguageExtension({ contentType, useTemplating });
|
||||
const state = EditorState.create({
|
||||
doc: `${defaultValue ?? ''}`,
|
||||
extensions: [
|
||||
langHolder.of(langExt),
|
||||
...getExtensions({
|
||||
container: el,
|
||||
readOnly,
|
||||
placeholder,
|
||||
singleLine,
|
||||
onChange,
|
||||
contentType,
|
||||
useTemplating,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const view = new EditorView({ state, parent: el });
|
||||
cm.current = { view, langHolder };
|
||||
syncGutterBg({ parent: el, className });
|
||||
if (autoFocus) view.focus();
|
||||
} catch (e) {
|
||||
console.log('Failed to initialize Codemirror', e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={initDivRef}
|
||||
className={classnames(
|
||||
className,
|
||||
'cm-wrapper text-base bg-gray-50',
|
||||
heightMode === 'auto' ? 'cm-auto-height' : 'cm-full-height',
|
||||
singleLine ? 'cm-singleline' : 'cm-multiline',
|
||||
readOnly && 'cm-readonly',
|
||||
)}
|
||||
>
|
||||
{contentType?.includes('graphql') && (
|
||||
<IconButton
|
||||
icon="eye"
|
||||
className="absolute right-3 bottom-3 z-10"
|
||||
onClick={() => {
|
||||
const doc = cm.current?.view.state.doc ?? '';
|
||||
const insert = formatSdl(doc.toString());
|
||||
cm.current?.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getExtensions({
|
||||
container,
|
||||
readOnly,
|
||||
singleLine,
|
||||
placeholder,
|
||||
onChange,
|
||||
contentType,
|
||||
useTemplating,
|
||||
}: Pick<
|
||||
_EditorProps,
|
||||
'singleLine' | 'onChange' | 'contentType' | 'useTemplating' | 'placeholder' | 'readOnly'
|
||||
> & { container: HTMLDivElement | null }) {
|
||||
const ext = getLanguageExtension({ contentType, useTemplating });
|
||||
|
||||
// TODO: Ensure tooltips render inside the dialog if we are in one.
|
||||
const parent =
|
||||
container?.closest<HTMLDivElement>('[role="dialog"]') ??
|
||||
document.querySelector<HTMLDivElement>('#cm-portal') ??
|
||||
undefined;
|
||||
|
||||
return [
|
||||
...baseExtensions,
|
||||
tooltips({ parent }),
|
||||
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
|
||||
...(singleLine ? [singleLineExt()] : []),
|
||||
...(!singleLine ? [multiLineExtensions] : []),
|
||||
...(ext ? [ext] : []),
|
||||
...(readOnly ? [EditorState.readOnly.of(true)] : []),
|
||||
...(placeholder ? [placeholderExt(placeholder)] : []),
|
||||
...(singleLine
|
||||
? [
|
||||
EditorView.domEventHandlers({
|
||||
focus: (e, view) => {
|
||||
// select all text on focus, like a regular input does
|
||||
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
||||
},
|
||||
keydown: (e) => {
|
||||
// Submit nearest form on enter if there is one
|
||||
if (e.key === 'Enter') {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
const form = el.closest('form');
|
||||
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
// Handle onChange
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (typeof onChange === 'function' && update.docChanged) {
|
||||
onChange(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const syncGutterBg = ({
|
||||
parent,
|
||||
className = '',
|
||||
}: {
|
||||
parent: HTMLDivElement;
|
||||
className?: string;
|
||||
}) => {
|
||||
const gutterEl = parent.querySelector<HTMLDivElement>('.cm-gutters');
|
||||
const classList = className?.split(/\s+/) ?? [];
|
||||
const bgClasses = classList
|
||||
.filter((c) => c.match(/(^|:)?bg-.+/)) // Find bg-* classes
|
||||
.map((c) => c.replace(/^bg-/, '!bg-')) // !important
|
||||
.map((c) => c.replace(/^dark:bg-/, 'dark:!bg-')); // !important
|
||||
if (gutterEl) {
|
||||
gutterEl?.classList.add(...bgClasses);
|
||||
}
|
||||
};
|
||||
35
src-web/components/core/Editor/autocomplete.ts
Normal file
35
src-web/components/core/Editor/autocomplete.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
|
||||
import { EditorView } from 'codemirror';
|
||||
import { debounce } from '../../../lib/debounce';
|
||||
|
||||
/*
|
||||
* Debounce autocomplete until user stops typing for `millis` milliseconds.
|
||||
*/
|
||||
export function debouncedAutocompletionDisplay({ millis }: { millis: number }) {
|
||||
// TODO: Figure out how to show completion without setting context.explicit = true
|
||||
const debouncedStartCompletion = debounce(function (view: EditorView) {
|
||||
startCompletion(view);
|
||||
}, millis);
|
||||
|
||||
return EditorView.updateListener.of(({ view, docChanged }) => {
|
||||
// const completions = currentCompletions(view.state);
|
||||
// const status = completionStatus(view.state);
|
||||
|
||||
if (!view.hasFocus) {
|
||||
debouncedStartCompletion.cancel();
|
||||
closeCompletion(view);
|
||||
return;
|
||||
}
|
||||
|
||||
if (view.state.doc.length === 0) {
|
||||
debouncedStartCompletion.cancel();
|
||||
closeCompletion(view);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the document hasn't changed, we don't need to do anything
|
||||
if (docChanged) {
|
||||
debouncedStartCompletion(view);
|
||||
}
|
||||
});
|
||||
}
|
||||
149
src-web/components/core/Editor/extensions.ts
Normal file
149
src-web/components/core/Editor/extensions.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
autocompletion,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
completionKeymap,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import type { LanguageSupport } from '@codemirror/language';
|
||||
import {
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
HighlightStyle,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from '@codemirror/language';
|
||||
import { lintKeymap } from '@codemirror/lint';
|
||||
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import {
|
||||
crosshairCursor,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
rectangularSelection,
|
||||
} from '@codemirror/view';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { graphqlLanguageSupport } from 'cm6-graphql';
|
||||
import { debouncedAutocompletionDisplay } from './autocomplete';
|
||||
import { twig } from './twig/extension';
|
||||
import { url } from './url/extension';
|
||||
|
||||
export const myHighlightStyle = HighlightStyle.define([
|
||||
{
|
||||
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
|
||||
color: '#757b93',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
{
|
||||
tag: [t.name, t.tagName, t.angleBracket, t.docString, t.number],
|
||||
color: 'hsl(var(--color-blue-600))',
|
||||
},
|
||||
{ tag: [t.variableName], color: 'hsl(var(--color-green-600))' },
|
||||
{ tag: [t.bool], color: 'hsl(var(--color-pink-600))' },
|
||||
{ tag: [t.attributeName], color: 'hsl(var(--color-violet-600))' },
|
||||
{ tag: [t.attributeValue], color: 'hsl(var(--color-orange-600))' },
|
||||
{ tag: [t.string], color: 'hsl(var(--color-yellow-600))' },
|
||||
{ tag: [t.keyword, t.meta, t.operator], color: 'hsl(var(--color-red-600))' },
|
||||
]);
|
||||
|
||||
// export const defaultHighlightStyle = HighlightStyle.define([
|
||||
// { tag: t.meta, color: '#404740' },
|
||||
// { tag: t.link, textDecoration: 'underline' },
|
||||
// { tag: t.heading, textDecoration: 'underline', fontWeight: 'bold' },
|
||||
// { tag: t.emphasis, fontStyle: 'italic' },
|
||||
// { tag: t.strong, fontWeight: 'bold' },
|
||||
// { tag: t.strikethrough, textDecoration: 'line-through' },
|
||||
// { tag: t.keyword, color: '#708' },
|
||||
// { tag: [t.atom, t.bool, t.url, t.contentSeparator, t.labelName], color: '#219' },
|
||||
// { tag: [t.literal, t.inserted], color: '#164' },
|
||||
// { tag: [t.string, t.deleted], color: '#a11' },
|
||||
// { tag: [t.regexp, t.escape, t.special(t.string)], color: '#e40' },
|
||||
// { tag: t.definition(t.variableName), color: '#00f' },
|
||||
// { tag: t.local(t.variableName), color: '#30a' },
|
||||
// { tag: [t.typeName, t.namespace], color: '#085' },
|
||||
// { tag: t.className, color: '#167' },
|
||||
// { tag: [t.special(t.variableName), t.macroName], color: '#256' },
|
||||
// { tag: t.definition(t.propertyName), color: '#00c' },
|
||||
// { tag: t.comment, color: '#940' },
|
||||
// { tag: t.invalid, color: '#f00' },
|
||||
// ]);
|
||||
|
||||
const syntaxExtensions: Record<string, LanguageSupport> = {
|
||||
'application/graphql+json': graphqlLanguageSupport(),
|
||||
'application/json': json(),
|
||||
'application/javascript': javascript(),
|
||||
'text/html': html(),
|
||||
'application/xml': xml(),
|
||||
'text/xml': xml(),
|
||||
url: url(),
|
||||
};
|
||||
|
||||
export function getLanguageExtension({
|
||||
contentType,
|
||||
useTemplating,
|
||||
}: {
|
||||
contentType?: string;
|
||||
useTemplating?: boolean;
|
||||
}) {
|
||||
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
|
||||
const base = syntaxExtensions[justContentType] ?? json();
|
||||
if (!useTemplating) {
|
||||
return [base];
|
||||
}
|
||||
|
||||
return twig(base);
|
||||
}
|
||||
|
||||
export const baseExtensions = [
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
bracketMatching(),
|
||||
// TODO: Figure out how to debounce showing of autocomplete in a good way
|
||||
// debouncedAutocompletionDisplay({ millis: 1000 }),
|
||||
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
|
||||
autocompletion({ closeOnBlur: true, interactionDelay: 300 }),
|
||||
syntaxHighlighting(myHighlightStyle),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
];
|
||||
|
||||
export const multiLineExtensions = [
|
||||
lineNumbers(),
|
||||
foldGutter({
|
||||
markerDOM: (open) => {
|
||||
const el = document.createElement('div');
|
||||
el.classList.add('fold-gutter-icon');
|
||||
el.tabIndex = -1;
|
||||
if (open) {
|
||||
el.setAttribute('data-open', '');
|
||||
}
|
||||
return el;
|
||||
},
|
||||
}),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
closeBrackets(),
|
||||
rectangularSelection(),
|
||||
crosshairCursor(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSelectionMatches({ minSelectionLength: 2 }),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...foldKeymap,
|
||||
...completionKeymap,
|
||||
...lintKeymap,
|
||||
]),
|
||||
];
|
||||
6
src-web/components/core/Editor/index.tsx
Normal file
6
src-web/components/core/Editor/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
import { _Editor } from './Editor';
|
||||
import type { _EditorProps } from './Editor';
|
||||
|
||||
export type EditorProps = _EditorProps;
|
||||
export const Editor = memo(_Editor);
|
||||
29
src-web/components/core/Editor/singleLine.ts
Normal file
29
src-web/components/core/Editor/singleLine.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Transaction, TransactionSpec } from '@codemirror/state';
|
||||
import { EditorSelection, EditorState } from '@codemirror/state';
|
||||
|
||||
export function singleLineExt() {
|
||||
return EditorState.transactionFilter.of(
|
||||
(tr: Transaction): TransactionSpec | TransactionSpec[] => {
|
||||
if (!tr.isUserEvent('input')) return tr;
|
||||
|
||||
const trs: TransactionSpec[] = [];
|
||||
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
||||
let insert = '';
|
||||
let newlinesRemoved = 0;
|
||||
for (const line of inserted) {
|
||||
const newLine = line.replace('\n', '');
|
||||
newlinesRemoved += line.length - newLine.length;
|
||||
insert += newLine;
|
||||
}
|
||||
|
||||
// Update cursor position based on how many newlines were removed
|
||||
const cursor = EditorSelection.cursor(toB - newlinesRemoved);
|
||||
const selection = EditorSelection.create([cursor], 0);
|
||||
|
||||
const changes = [{ from: fromB, to: toA, insert }];
|
||||
trs.push({ ...tr, selection, changes });
|
||||
});
|
||||
return trs;
|
||||
},
|
||||
);
|
||||
}
|
||||
55
src-web/components/core/Editor/twig/completion.ts
Normal file
55
src-web/components/core/Editor/twig/completion.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { CompletionContext } from '@codemirror/autocomplete';
|
||||
|
||||
const openTag = '${[ ';
|
||||
const closeTag = ' ]}';
|
||||
|
||||
const variables = [
|
||||
{ name: 'DOMAIN' },
|
||||
{ name: 'BASE_URL' },
|
||||
{ name: 'TOKEN' },
|
||||
{ name: 'PROJECT_ID' },
|
||||
{ name: 'DUMMY' },
|
||||
{ name: 'DUMMY_2' },
|
||||
{ name: 'STRIPE_PUB_KEY' },
|
||||
{ name: 'RAILWAY_TOKEN' },
|
||||
{ name: 'SECRET' },
|
||||
{ name: 'PORT' },
|
||||
];
|
||||
|
||||
const MIN_MATCH_VAR = 2;
|
||||
const MIN_MATCH_NAME = 4;
|
||||
|
||||
export function completions(context: CompletionContext) {
|
||||
const toStartOfName = context.matchBefore(/\w*/);
|
||||
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*\w*/);
|
||||
const toMatch = toStartOfVariable ?? toStartOfName ?? null;
|
||||
|
||||
if (toMatch === null) return null;
|
||||
|
||||
const matchLen = toMatch.to - toMatch.from;
|
||||
|
||||
const failedVarLen = toStartOfVariable !== null && matchLen < MIN_MATCH_VAR;
|
||||
if (failedVarLen && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failedNameLen = toStartOfVariable === null && matchLen < MIN_MATCH_NAME;
|
||||
if (failedNameLen && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Figure out how to make autocomplete stay open if opened explicitly. It sucks when you explicitly
|
||||
// open it, then it closes when you type the next character.
|
||||
return {
|
||||
from: toMatch.from,
|
||||
options: variables
|
||||
.map((v) => ({
|
||||
label: toStartOfVariable ? `${openTag}${v.name}${closeTag}` : v.name,
|
||||
apply: `${openTag}${v.name}${closeTag}`,
|
||||
type: 'variable',
|
||||
matchLen,
|
||||
}))
|
||||
// Filter out exact matches
|
||||
.filter((o) => o.label !== toMatch.text),
|
||||
};
|
||||
}
|
||||
41
src-web/components/core/Editor/twig/extension.ts
Normal file
41
src-web/components/core/Editor/twig/extension.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import { parseMixed } from '@lezer/common';
|
||||
import { completions } from './completion';
|
||||
import { placeholders } from '../widgets';
|
||||
import { parser as twigParser } from './twig';
|
||||
|
||||
export function twig(base?: LanguageSupport) {
|
||||
const language = mixedOrPlainLanguage(base);
|
||||
const completion = language.data.of({
|
||||
autocomplete: completions,
|
||||
});
|
||||
const languageSupport = new LanguageSupport(language, [completion]);
|
||||
|
||||
if (base) {
|
||||
const completion2 = base.language.data.of({ autocomplete: completions });
|
||||
const languageSupport2 = new LanguageSupport(base.language, [completion2]);
|
||||
return [languageSupport, languageSupport2, placeholders, base.support];
|
||||
} else {
|
||||
return [languageSupport, placeholders];
|
||||
}
|
||||
}
|
||||
|
||||
function mixedOrPlainLanguage(base?: LanguageSupport): LRLanguage {
|
||||
const name = 'twig';
|
||||
|
||||
if (base == null) {
|
||||
return LRLanguage.define({ name, parser: twigParser });
|
||||
}
|
||||
|
||||
const parser = twigParser.configure({
|
||||
wrap: parseMixed((node) => {
|
||||
if (!node.type.isTop) return null;
|
||||
return {
|
||||
parser: base.language.parser,
|
||||
overlay: (node) => node.type.name === 'Text',
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
return LRLanguage.define({ name, parser });
|
||||
}
|
||||
7
src-web/components/core/Editor/twig/highlight.ts
Normal file
7
src-web/components/core/Editor/twig/highlight.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const highlight = styleTags({
|
||||
'if endif': t.controlKeyword,
|
||||
'${[ ]}': t.meta,
|
||||
DirectiveContent: t.variableName,
|
||||
});
|
||||
19
src-web/components/core/Editor/twig/twig.grammar
Normal file
19
src-web/components/core/Editor/twig/twig.grammar
Normal file
@@ -0,0 +1,19 @@
|
||||
@top Template { (directive | Text)* }
|
||||
|
||||
directive {
|
||||
Insert
|
||||
}
|
||||
|
||||
@skip {space} {
|
||||
Insert { "${[" DirectiveContent "]}" }
|
||||
}
|
||||
|
||||
@tokens {
|
||||
Text { ![$] Text? }
|
||||
space { @whitespace+ }
|
||||
DirectiveContent { ![\]}] DirectiveContent? }
|
||||
@precedence { space DirectiveContent }
|
||||
"${[" "]}"
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||
6
src-web/components/core/Editor/twig/twig.terms.ts
Normal file
6
src-web/components/core/Editor/twig/twig.terms.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
Template = 1,
|
||||
Insert = 2,
|
||||
DirectiveContent = 4,
|
||||
Text = 6
|
||||
18
src-web/components/core/Editor/twig/twig.ts
Normal file
18
src-web/components/core/Editor/twig/twig.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {highlight} from "./highlight"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "zQVOPOOO_QQO'#C^OOOO'#Cc'#CcQVOPOOOdQQO,58xOOOO-E6a-E6aOOOO1G.d1G.d",
|
||||
stateData: "l~OYOS~ORPOUQO~OSSO~OTUO~OYS~",
|
||||
goto: "cWPPXPPPP]TQORQRORTR",
|
||||
nodeNames: "⚠ Template Insert ${[ DirectiveContent ]} Text",
|
||||
maxTerm: 10,
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData: ")gRRmOX!|X^$y^p!|pq$yqt!|tu&}u#P!|#P#Q(k#Q#q!|#q#r$[#r#y!|#y#z$y#z$f!|$f$g$y$g#BY!|#BY#BZ$y#BZ$IS!|$IS$I_$y$I_$I|!|$I|$JO$y$JO$JT!|$JT$JU$y$JU$KV!|$KV$KW$y$KW&FU!|&FU&FV$y&FV;'S!|;'S;=`$s<%lO!|R#TXUPSQOt!|tu#pu#P!|#P#Q$[#Q#q!|#q#r$[#r;'S!|;'S;=`$s<%lO!|Q#uTSQO#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pQ$XP;=`<%l#pP$aSUPOt$[u;'S$[;'S;=`$m<%lO$[P$pP;=`<%l$[R$vP;=`<%l!|R%SmUPYQSQOX!|X^$y^p!|pq$yqt!|tu#pu#P!|#P#Q$[#Q#q!|#q#r$[#r#y!|#y#z$y#z$f!|$f$g$y$g#BY!|#BY#BZ$y#BZ$IS!|$IS$I_$y$I_$I|!|$I|$JO$y$JO$JT!|$JT$JU$y$JU$KV!|$KV$KW$y$KW&FU!|&FU&FV$y&FV;'S!|;'S;=`$s<%lO!|R'SVSQO#P#p#Q#o#p#o#p'i#p#q#p#r;'S#p;'S;=`$U<%lO#pR'nVSQO!}#p!}#O(T#O#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pR([TRPSQO#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pR(pUUPOt$[u#q$[#q#r)S#r;'S$[;'S;=`$m<%lO$[R)ZSTQUPOt$[u;'S$[;'S;=`$m<%lO$[",
|
||||
tokenizers: [0, 1],
|
||||
topRules: {"Template":[0,1]},
|
||||
tokenPrec: 25
|
||||
})
|
||||
19
src-web/components/core/Editor/url/completion.ts
Normal file
19
src-web/components/core/Editor/url/completion.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { CompletionContext } from '@codemirror/autocomplete';
|
||||
|
||||
const options = [
|
||||
{ label: 'http://', type: 'constant' },
|
||||
{ label: 'https://', type: 'constant' },
|
||||
];
|
||||
|
||||
const MIN_MATCH = 1;
|
||||
|
||||
export function completions(context: CompletionContext) {
|
||||
const toMatch = context.matchBefore(/^[\w:/]*/);
|
||||
if (toMatch === null) return null;
|
||||
|
||||
const matchedMinimumLength = toMatch.to - toMatch.from >= MIN_MATCH;
|
||||
if (!matchedMinimumLength && !context.explicit) return null;
|
||||
|
||||
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text);
|
||||
return { from: toMatch.from, options: optionsWithoutExactMatches };
|
||||
}
|
||||
14
src-web/components/core/Editor/url/extension.ts
Normal file
14
src-web/components/core/Editor/url/extension.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import { completions } from './completion';
|
||||
import { parser } from './url';
|
||||
|
||||
const urlLanguage = LRLanguage.define({
|
||||
parser,
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
const completion = urlLanguage.data.of({ autocomplete: completions });
|
||||
|
||||
export function url() {
|
||||
return new LanguageSupport(urlLanguage, [completion]);
|
||||
}
|
||||
9
src-web/components/core/Editor/url/highlight.ts
Normal file
9
src-web/components/core/Editor/url/highlight.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const highlight = styleTags({
|
||||
Protocol: t.comment,
|
||||
// Port: t.attributeName,
|
||||
// Host: t.variableName,
|
||||
// Path: t.bool,
|
||||
// Query: t.string,
|
||||
});
|
||||
18
src-web/components/core/Editor/url/url.grammar
Normal file
18
src-web/components/core/Editor/url/url.grammar
Normal file
@@ -0,0 +1,18 @@
|
||||
@top url { Protocol? Host Port? Path? Query? }
|
||||
|
||||
Query {
|
||||
"?" queryPair ("&" queryPair)*
|
||||
}
|
||||
|
||||
@tokens {
|
||||
Protocol { $[a-zA-Z]+ "://" }
|
||||
Path { ("/" $[a-zA-Z0-9\-_.]*)+ }
|
||||
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
|
||||
Port { ":" $[0-9]+ }
|
||||
Host { $[a-zA-Z0-9-_.]+ }
|
||||
|
||||
// Protocol/host overlaps, so give proto explicit precedence
|
||||
@precedence { Protocol, Host }
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||
8
src-web/components/core/Editor/url/url.terms.ts
Normal file
8
src-web/components/core/Editor/url/url.terms.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
url = 1,
|
||||
Protocol = 2,
|
||||
Host = 3,
|
||||
Port = 4,
|
||||
Path = 5,
|
||||
Query = 6
|
||||
18
src-web/components/core/Editor/url/url.ts
Normal file
18
src-web/components/core/Editor/url/url.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {highlight} from "./highlight"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "!jOQOPOOQYOPOOOTOPOOOeOQO'#CbQOOOOOQ`OPOOQ]OPOOOjOPO,58|OrOQO'#CcOwOPO1G.hOOOO,58},58}OOOO-E6a-E6a",
|
||||
stateData: "!S~OQQORPO~OSUOTTOXRO~OYVO~OZWOWUa~OYYO~OZWOWUi~OQR~",
|
||||
goto: "dWPPPPPPX^VSPTUQXVRZX",
|
||||
nodeNames: "⚠ url Protocol Host Port Path Query",
|
||||
maxTerm: 11,
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData: "%[~RYvwq}!Ov!O!Pv!P!Q!_!Q![!y![!]#u!a!b$T!c!}$Y#R#Sv#T#o$Y~vOZ~P{URP}!Ov!O!Pv!Q![v!c!}v#R#Sv#T#ov~!dVT~}!O!_!O!P!_!P!Q!_!Q![!_!c!}!_#R#S!_#T#o!_R#QVYQRP}!Ov!O!Pv!Q![!y!_!`#g!c!}!y#R#Sv#T#o!yQ#lRYQ!Q![#g!c!}#g#T#o#g~#xP!Q![#{~$QPS~!Q![#{~$YOX~R$aWYQRP}!Ov!O!Pv!Q![!y![!]$y!_!`#g!c!}$Y#R#Sv#T#o$YP$|P!P!Q%PP%SP!P!Q%VP%[OQP",
|
||||
tokenizers: [0, 1],
|
||||
topRules: {"url":[0,1]},
|
||||
tokenPrec: 47
|
||||
})
|
||||
76
src-web/components/core/Editor/widgets.ts
Normal file
76
src-web/components/core/Editor/widgets.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
|
||||
class PlaceholderWidget extends WidgetType {
|
||||
constructor(readonly name: string) {
|
||||
super();
|
||||
}
|
||||
eq(other: PlaceholderWidget) {
|
||||
return this.name == other.name;
|
||||
}
|
||||
toDOM() {
|
||||
const elt = document.createElement('span');
|
||||
elt.className = 'placeholder-widget';
|
||||
elt.textContent = this.name;
|
||||
return elt;
|
||||
}
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
|
||||
*/
|
||||
class BetterMatchDecorator extends MatchDecorator {
|
||||
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
|
||||
if (!update.startState.selection.eq(update.state.selection)) {
|
||||
return super.createDeco(update.view);
|
||||
} else {
|
||||
return super.updateDeco(update, deco);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const placeholderMatcher = new BetterMatchDecorator({
|
||||
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
|
||||
decoration(match, view, matchStartPos) {
|
||||
const matchEndPos = matchStartPos + match[0].length - 1;
|
||||
|
||||
// Don't decorate if the cursor is inside the match
|
||||
for (const r of view.state.selection.ranges) {
|
||||
if (r.from > matchStartPos && r.to <= matchEndPos) return null;
|
||||
}
|
||||
|
||||
const groupMatch = match[1];
|
||||
if (groupMatch == null) {
|
||||
// Should never happen, but make TS happy
|
||||
console.warn('Group match was empty', match);
|
||||
return null;
|
||||
}
|
||||
|
||||
return Decoration.replace({
|
||||
inclusive: true,
|
||||
widget: new PlaceholderWidget(groupMatch),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const placeholders = ViewPlugin.fromClass(
|
||||
class {
|
||||
placeholders: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.placeholders = placeholderMatcher.createDeco(view);
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (instance) => instance.placeholders,
|
||||
provide: (plugin) =>
|
||||
EditorView.atomicRanges.of((view) => {
|
||||
return view.plugin(plugin)?.placeholders || Decoration.none;
|
||||
}),
|
||||
},
|
||||
);
|
||||
15
src-web/components/core/Heading.tsx
Normal file
15
src-web/components/core/Heading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function Heading({ className, children, ...props }: Props) {
|
||||
return (
|
||||
<h1 className={classnames(className, 'text-2xl font-semibold text-gray-900 mb-3')} {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
15
src-web/components/core/HotKey.tsx
Normal file
15
src-web/components/core/HotKey.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import classnames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
export function HotKey({ children }: HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span
|
||||
className={classnames(
|
||||
'bg-gray-400 bg-opacity-20 px-1.5 py-0.5 rounded text-sm',
|
||||
'font-mono text-gray-500 tracking-widest',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
75
src-web/components/core/Icon.tsx
Normal file
75
src-web/components/core/Icon.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
ArchiveIcon,
|
||||
CameraIcon,
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
CodeIcon,
|
||||
ColorWheelIcon,
|
||||
Cross2Icon,
|
||||
EyeOpenIcon,
|
||||
GearIcon,
|
||||
HomeIcon,
|
||||
MoonIcon,
|
||||
ListBulletIcon,
|
||||
PaperPlaneIcon,
|
||||
PlusCircledIcon,
|
||||
PlusIcon,
|
||||
QuestionMarkIcon,
|
||||
SunIcon,
|
||||
TrashIcon,
|
||||
TriangleDownIcon,
|
||||
TriangleLeftIcon,
|
||||
TriangleRightIcon,
|
||||
UpdateIcon,
|
||||
RowsIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const icons = {
|
||||
archive: ArchiveIcon,
|
||||
camera: CameraIcon,
|
||||
check: CheckIcon,
|
||||
clock: ClockIcon,
|
||||
code: CodeIcon,
|
||||
colorWheel: ColorWheelIcon,
|
||||
eye: EyeOpenIcon,
|
||||
gear: GearIcon,
|
||||
home: HomeIcon,
|
||||
listBullet: ListBulletIcon,
|
||||
moon: MoonIcon,
|
||||
paperPlane: PaperPlaneIcon,
|
||||
plus: PlusIcon,
|
||||
plusCircle: PlusCircledIcon,
|
||||
question: QuestionMarkIcon,
|
||||
rows: RowsIcon,
|
||||
sun: SunIcon,
|
||||
trash: TrashIcon,
|
||||
triangleDown: TriangleDownIcon,
|
||||
triangleLeft: TriangleLeftIcon,
|
||||
triangleRight: TriangleRightIcon,
|
||||
update: UpdateIcon,
|
||||
x: Cross2Icon,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
icon: keyof typeof icons;
|
||||
className?: string;
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
spin?: boolean;
|
||||
}
|
||||
|
||||
export function Icon({ icon, spin, size = 'md', className }: IconProps) {
|
||||
const Component = icons[icon] ?? icons.question;
|
||||
return (
|
||||
<Component
|
||||
className={classnames(
|
||||
className,
|
||||
'text-inherit',
|
||||
size === 'md' && 'h-4 w-4',
|
||||
size === 'sm' && 'h-3 w-3',
|
||||
size === 'xs' && 'h-2 w-2',
|
||||
spin && 'animate-spin',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
35
src-web/components/core/IconButton.tsx
Normal file
35
src-web/components/core/IconButton.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import classnames from 'classnames';
|
||||
import { forwardRef } from 'react';
|
||||
import type { ButtonProps } from './Button';
|
||||
import { Button } from './Button';
|
||||
import type { IconProps } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
type Props = IconProps & ButtonProps & { iconClassName?: string; iconSize?: IconProps['size'] };
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
|
||||
{ icon, spin, className, iconClassName, size = 'md', iconSize, ...props }: Props,
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
className={classnames(
|
||||
className,
|
||||
'text-gray-700 hover:text-gray-1000',
|
||||
'!px-0',
|
||||
size === 'md' && 'w-9',
|
||||
size === 'sm' && 'w-9',
|
||||
)}
|
||||
size={size}
|
||||
{...props}
|
||||
>
|
||||
<Icon
|
||||
size={iconSize}
|
||||
icon={icon}
|
||||
spin={spin}
|
||||
className={classnames(iconClassName, props.disabled && 'opacity-70')}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
96
src-web/components/core/Input.tsx
Normal file
96
src-web/components/core/Input.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { EditorProps } from './Editor';
|
||||
import { Editor } from './Editor';
|
||||
import { HStack, VStack } from './Stacks';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
hideLabel?: boolean;
|
||||
labelClassName?: string;
|
||||
containerClassName?: string;
|
||||
onChange?: (value: string) => void;
|
||||
useEditor?: Pick<EditorProps, 'contentType' | 'useTemplating'>;
|
||||
defaultValue?: string;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
label,
|
||||
hideLabel,
|
||||
className,
|
||||
containerClassName,
|
||||
labelClassName,
|
||||
onChange,
|
||||
placeholder,
|
||||
size = 'md',
|
||||
useEditor,
|
||||
name,
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
defaultValue,
|
||||
...props
|
||||
}: Props) {
|
||||
const id = `input-${name}`;
|
||||
const inputClassName = classnames(
|
||||
className,
|
||||
'!bg-transparent pl-3 pr-2 min-w-0 h-full w-full focus:outline-none placeholder:text-placeholder',
|
||||
!!leftSlot && '!pl-0.5',
|
||||
!!rightSlot && '!pr-0.5',
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={classnames(
|
||||
labelClassName,
|
||||
'font-semibold text-sm uppercase text-gray-700',
|
||||
hideLabel && 'sr-only',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<HStack
|
||||
alignItems="center"
|
||||
className={classnames(
|
||||
containerClassName,
|
||||
'relative w-full rounded-md text-gray-900',
|
||||
'border border-gray-200 focus-within:border-blue-400/40',
|
||||
size === 'md' && 'h-9',
|
||||
size === 'sm' && 'h-7',
|
||||
)}
|
||||
>
|
||||
{leftSlot}
|
||||
{useEditor ? (
|
||||
<Editor
|
||||
id={id}
|
||||
singleLine
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
className={inputClassName}
|
||||
{...props}
|
||||
{...useEditor}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={id}
|
||||
onChange={(e) => onChange?.(e.currentTarget.value)}
|
||||
placeholder={placeholder}
|
||||
defaultValue={defaultValue}
|
||||
className={inputClassName}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
{rightSlot}
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
34
src-web/components/core/ScrollArea.tsx
Normal file
34
src-web/components/core/ScrollArea.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as S from '@radix-ui/react-scroll-area';
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScrollArea({ children, className }: Props) {
|
||||
return (
|
||||
<S.Root className={classnames(className, 'group/scroll')} type="always">
|
||||
<S.Viewport>{children}</S.Viewport>
|
||||
<ScrollBar orientation="vertical" />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
<S.Corner />
|
||||
</S.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({ orientation }: { orientation: 'vertical' | 'horizontal' }) {
|
||||
return (
|
||||
<S.Scrollbar
|
||||
orientation={orientation}
|
||||
className={classnames(
|
||||
'flex bg-transparent rounded-full',
|
||||
orientation === 'vertical' && 'w-1.5',
|
||||
orientation === 'horizontal' && 'h-1.5 flex-col',
|
||||
)}
|
||||
>
|
||||
<S.Thumb className="flex-1 bg-gray-100 group-hover/scroll:bg-gray-200 rounded-full" />
|
||||
</S.Scrollbar>
|
||||
);
|
||||
}
|
||||
106
src-web/components/core/Stacks.tsx
Normal file
106
src-web/components/core/Stacks.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import { Children, Fragment } from 'react';
|
||||
|
||||
const spaceClassesX = {
|
||||
0: 'pr-0',
|
||||
1: 'pr-1',
|
||||
2: 'pr-2',
|
||||
3: 'pr-3',
|
||||
4: 'pr-4',
|
||||
5: 'pr-5',
|
||||
6: 'pr-6',
|
||||
};
|
||||
|
||||
const spaceClassesY = {
|
||||
0: 'pt-0',
|
||||
1: 'pt-1',
|
||||
2: 'pt-2',
|
||||
3: 'pt-3',
|
||||
4: 'pt-4',
|
||||
5: 'pt-5',
|
||||
6: 'pt-6',
|
||||
};
|
||||
|
||||
interface HStackProps extends BaseStackProps {
|
||||
space?: keyof typeof spaceClassesX;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function HStack({ className, space, children, ...props }: HStackProps) {
|
||||
return (
|
||||
<BaseStack className={classnames(className, 'flex-row')} {...props}>
|
||||
{space
|
||||
? Children.toArray(children)
|
||||
.filter(Boolean) // Remove null/false/undefined children
|
||||
.map((c, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 ? (
|
||||
<div
|
||||
className={classnames(spaceClassesX[space], 'pointer-events-none')}
|
||||
data-spacer=""
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{c}
|
||||
</Fragment>
|
||||
))
|
||||
: children}
|
||||
</BaseStack>
|
||||
);
|
||||
}
|
||||
|
||||
export interface VStackProps extends BaseStackProps {
|
||||
space?: keyof typeof spaceClassesY;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function VStack({ className, space, children, ...props }: VStackProps) {
|
||||
return (
|
||||
<BaseStack className={classnames(className, 'w-full h-full flex-col')} {...props}>
|
||||
{space
|
||||
? Children.toArray(children)
|
||||
.filter(Boolean) // Remove null/false/undefined children
|
||||
.map((c, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 ? (
|
||||
<div
|
||||
className={classnames(spaceClassesY[space], 'pointer-events-none')}
|
||||
data-spacer=""
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{c}
|
||||
</Fragment>
|
||||
))
|
||||
: children}
|
||||
</BaseStack>
|
||||
);
|
||||
}
|
||||
|
||||
interface BaseStackProps {
|
||||
as?: ComponentType | 'ul';
|
||||
alignItems?: 'start' | 'center';
|
||||
justifyContent?: 'start' | 'center' | 'end';
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function BaseStack({ className, alignItems, justifyContent, children, as }: BaseStackProps) {
|
||||
const Component = as ?? 'div';
|
||||
return (
|
||||
<Component
|
||||
className={classnames(
|
||||
className,
|
||||
'flex',
|
||||
alignItems === 'center' && 'items-center',
|
||||
alignItems === 'start' && 'items-start',
|
||||
justifyContent === 'start' && 'justify-start',
|
||||
justifyContent === 'center' && 'justify-center',
|
||||
justifyContent === 'end' && 'justify-end',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
23
src-web/components/core/StatusColor.tsx
Normal file
23
src-web/components/core/StatusColor.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
statusCode: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function StatusColor({ statusCode, children }: Props) {
|
||||
return (
|
||||
<span
|
||||
className={classnames(
|
||||
statusCode >= 100 && statusCode < 200 && 'text-green-600',
|
||||
statusCode >= 200 && statusCode < 300 && 'text-green-600',
|
||||
statusCode >= 300 && statusCode < 400 && 'text-pink-600',
|
||||
statusCode >= 400 && statusCode < 500 && 'text-orange-600',
|
||||
statusCode >= 500 && statusCode < 600 && 'text-red-600',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
5
src-web/components/core/Tabs/Tabs.css
Normal file
5
src-web/components/core/Tabs/Tabs.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.tab-content {
|
||||
&[data-state="inactive"] {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
88
src-web/components/core/Tabs/Tabs.tsx
Normal file
88
src-web/components/core/Tabs/Tabs.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as T from '@radix-ui/react-tabs';
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../Button';
|
||||
import { ScrollArea } from '../ScrollArea';
|
||||
import { HStack } from '../Stacks';
|
||||
|
||||
import './Tabs.css';
|
||||
|
||||
interface Props {
|
||||
defaultValue?: string;
|
||||
label: string;
|
||||
tabs: { value: string; label: ReactNode }[];
|
||||
tabListClassName?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Tabs({ defaultValue, label, children, tabs, className, tabListClassName }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
return (
|
||||
<T.Root
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={setValue}
|
||||
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
|
||||
>
|
||||
<T.List
|
||||
aria-label={label}
|
||||
className={classnames(
|
||||
tabListClassName,
|
||||
'h-auto flex items-center overflow-x-auto mb-1 pb-1',
|
||||
)}
|
||||
>
|
||||
{/*<ScrollArea className="w-full pb-2">*/}
|
||||
<HStack space={1}>
|
||||
{tabs.map((t) => (
|
||||
<TabTrigger key={t.value} value={t.value} active={t.value === value}>
|
||||
{t.label}
|
||||
</TabTrigger>
|
||||
))}
|
||||
</HStack>
|
||||
{/*</ScrollArea>*/}
|
||||
</T.List>
|
||||
{children}
|
||||
</T.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabTriggerProps {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export function TabTrigger({ value, children, active }: TabTriggerProps) {
|
||||
return (
|
||||
<T.Trigger value={value} asChild>
|
||||
<Button
|
||||
color="custom"
|
||||
size="sm"
|
||||
className={classnames(
|
||||
active ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</T.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabContentProps {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TabContent({ value, children, className }: TabContentProps) {
|
||||
return (
|
||||
<T.Content
|
||||
forceMount
|
||||
value={value}
|
||||
className={classnames(className, 'tab-content', 'w-full h-full overflow-auto')}
|
||||
>
|
||||
{children}
|
||||
</T.Content>
|
||||
);
|
||||
}
|
||||
28
src-web/components/core/Webview.tsx
Normal file
28
src-web/components/core/Webview.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface Props {
|
||||
body: string;
|
||||
contentType: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function Webview({ body, url, contentType }: Props) {
|
||||
const contentForIframe: string | undefined = useMemo(() => {
|
||||
if (!contentType.includes('html')) return;
|
||||
if (body.includes('<head>')) {
|
||||
return body.replace(/<head>/gi, `<head><base href="${url}"/>`);
|
||||
}
|
||||
return body;
|
||||
}, [body, contentType]);
|
||||
|
||||
return (
|
||||
<div className="px-2 pb-2">
|
||||
<iframe
|
||||
title="Response preview"
|
||||
srcDoc={contentForIframe}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
className="h-full w-full rounded-md border border-gray-100/20"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src-web/components/core/WindowDragRegion.tsx
Normal file
17
src-web/components/core/WindowDragRegion.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function WindowDragRegion({ className, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={classnames(className, 'w-full h-12 flex-shrink-0')}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user