Better code splitting and removed final instances of react-dnd

This commit is contained in:
Gregory Schier
2025-10-19 08:16:56 -07:00
parent 8055b625d0
commit ba6163b6d8
32 changed files with 654 additions and 605 deletions

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react';
import { generateId } from '../../lib/generateId';
import { Editor } from './Editor/Editor';
import { Editor } from './Editor/LazyEditor';
import type { Pair, PairEditorProps, PairWithId } from './PairEditor';
type Props = PairEditorProps;

View File

@@ -0,0 +1,13 @@
import type { EditorView } from '@codemirror/view';
import { forwardRef, lazy, Suspense } from 'react';
import type { EditorProps } from './Editor';
const Editor_ = lazy(() => import('./Editor').then((m) => ({ default: m.Editor })));
export const Editor = forwardRef<EditorView, EditorProps>(function LazyEditor(props, ref) {
return (
<Suspense>
<Editor_ ref={ref} {...props} />
</Suspense>
);
});

View File

@@ -1,127 +1,245 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import * as lucide from 'lucide-react';
import {
AlertTriangleIcon,
ArchiveIcon,
ArrowBigDownDashIcon,
ArrowBigLeftDashIcon,
ArrowBigRightDashIcon,
ArrowBigRightIcon,
ArrowBigUpDashIcon,
ArrowDownIcon,
ArrowDownToDotIcon,
ArrowDownToLineIcon,
ArrowRightCircleIcon,
ArrowUpDownIcon,
ArrowUpFromDotIcon,
ArrowUpFromLineIcon,
ArrowUpIcon,
BadgeCheckIcon,
BookOpenText,
BoxIcon,
CakeIcon,
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
CircleAlertIcon,
CircleDashedIcon,
CircleDollarSignIcon,
CircleFadingArrowUpIcon,
CircleHelpIcon,
ClipboardPasteIcon,
ClockIcon,
CodeIcon,
Columns2Icon,
CommandIcon,
CookieIcon,
CopyCheck,
CopyIcon,
CornerRightUpIcon,
CreditCardIcon,
DotIcon,
DownloadIcon,
EllipsisIcon,
ExpandIcon,
ExternalLinkIcon,
EyeIcon,
EyeOffIcon,
FileCodeIcon,
FileTextIcon,
FilterIcon,
FlameIcon,
FlaskConicalIcon,
FolderCodeIcon,
FolderCogIcon,
FolderGitIcon,
FolderIcon,
FolderInputIcon,
FolderOpenIcon,
FolderOutputIcon,
FolderSymlinkIcon,
FolderSyncIcon,
FolderUpIcon,
GitBranchIcon,
GitBranchPlusIcon,
GitCommitIcon,
GitCommitVerticalIcon,
GitForkIcon,
GitPullRequestIcon,
GripVerticalIcon,
HandIcon,
HistoryIcon,
HomeIcon,
ImportIcon,
InfoIcon,
KeyboardIcon,
KeyRoundIcon,
LockIcon,
LockOpenIcon,
MergeIcon,
MessageSquare,
MinusCircleIcon,
MinusIcon,
MoonIcon,
MoreVerticalIcon,
PaletteIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PencilIcon,
PinIcon,
PinOffIcon,
Plug,
PlusCircleIcon,
PlusIcon,
PuzzleIcon,
RefreshCcwIcon,
RefreshCwIcon,
RocketIcon,
Rows2Icon,
SaveIcon,
SearchIcon,
SendHorizonalIcon,
SettingsIcon,
ShieldAlertIcon,
ShieldCheckIcon,
ShieldIcon,
ShieldOffIcon,
SparklesIcon,
SquareCheckIcon,
SquareIcon,
SquareTerminalIcon,
SunIcon,
TableIcon,
Trash2Icon,
UploadIcon,
VariableIcon,
Wand2Icon,
WrenchIcon,
XIcon,
} from 'lucide-react';
import type { CSSProperties, HTMLAttributes } from 'react';
import { memo } from 'react';
const icons = {
alert_triangle: lucide.AlertTriangleIcon,
archive: lucide.ArchiveIcon,
arrow_big_down_dash: lucide.ArrowBigDownDashIcon,
arrow_big_left_dash: lucide.ArrowBigLeftDashIcon,
arrow_big_right: lucide.ArrowBigRightIcon,
arrow_big_right_dash: lucide.ArrowBigRightDashIcon,
arrow_big_up_dash: lucide.ArrowBigUpDashIcon,
arrow_down: lucide.ArrowDownIcon,
arrow_down_to_dot: lucide.ArrowDownToDotIcon,
arrow_down_to_line: lucide.ArrowDownToLineIcon,
arrow_right_circle: lucide.ArrowRightCircleIcon,
arrow_up: lucide.ArrowUpIcon,
arrow_up_down: lucide.ArrowUpDownIcon,
arrow_up_from_dot: lucide.ArrowUpFromDotIcon,
arrow_up_from_line: lucide.ArrowUpFromLineIcon,
badge_check: lucide.BadgeCheckIcon,
book_open_text: lucide.BookOpenText,
box: lucide.BoxIcon,
cake: lucide.CakeIcon,
chat: lucide.MessageSquare,
check: lucide.CheckIcon,
check_circle: lucide.CheckCircleIcon,
check_square_checked: lucide.SquareCheckIcon,
check_square_unchecked: lucide.SquareIcon,
chevron_down: lucide.ChevronDownIcon,
chevron_left: lucide.ChevronLeftIcon,
chevron_right: lucide.ChevronRightIcon,
circle_alert: lucide.CircleAlertIcon,
circle_dashed: lucide.CircleDashedIcon,
circle_dollar_sign: lucide.CircleDollarSignIcon,
circle_fading_arrow_up: lucide.CircleFadingArrowUpIcon,
clock: lucide.ClockIcon,
code: lucide.CodeIcon,
columns_2: lucide.Columns2Icon,
command: lucide.CommandIcon,
cookie: lucide.CookieIcon,
copy: lucide.CopyIcon,
copy_check: lucide.CopyCheck,
corner_right_up: lucide.CornerRightUpIcon,
credit_card: lucide.CreditCardIcon,
dot: lucide.DotIcon,
download: lucide.DownloadIcon,
ellipsis: lucide.EllipsisIcon,
expand: lucide.ExpandIcon,
external_link: lucide.ExternalLinkIcon,
eye: lucide.EyeIcon,
eye_closed: lucide.EyeOffIcon,
file_code: lucide.FileCodeIcon,
filter: lucide.FilterIcon,
flame: lucide.FlameIcon,
flask: lucide.FlaskConicalIcon,
folder: lucide.FolderIcon,
folder_code: lucide.FolderCodeIcon,
folder_cog: lucide.FolderCogIcon,
folder_git: lucide.FolderGitIcon,
folder_input: lucide.FolderInputIcon,
folder_open: lucide.FolderOpenIcon,
folder_output: lucide.FolderOutputIcon,
folder_symlink: lucide.FolderSymlinkIcon,
folder_sync: lucide.FolderSyncIcon,
folder_up: lucide.FolderUpIcon,
git_branch: lucide.GitBranchIcon,
git_branch_plus: lucide.GitBranchPlusIcon,
git_commit: lucide.GitCommitIcon,
git_commit_vertical: lucide.GitCommitVerticalIcon,
git_fork: lucide.GitForkIcon,
git_pull_request: lucide.GitPullRequestIcon,
grip_vertical: lucide.GripVerticalIcon,
hand: lucide.HandIcon,
help: lucide.CircleHelpIcon,
history: lucide.HistoryIcon,
house: lucide.HomeIcon,
import: lucide.ImportIcon,
info: lucide.InfoIcon,
key_round: lucide.KeyRoundIcon,
keyboard: lucide.KeyboardIcon,
left_panel_hidden: lucide.PanelLeftOpenIcon,
left_panel_visible: lucide.PanelLeftCloseIcon,
lock: lucide.LockIcon,
lock_open: lucide.LockOpenIcon,
magic_wand: lucide.Wand2Icon,
merge: lucide.MergeIcon,
minus: lucide.MinusIcon,
minus_circle: lucide.MinusCircleIcon,
moon: lucide.MoonIcon,
more_vertical: lucide.MoreVerticalIcon,
palette: lucide.PaletteIcon,
paste: lucide.ClipboardPasteIcon,
pencil: lucide.PencilIcon,
pin: lucide.PinIcon,
plug: lucide.Plug,
plus: lucide.PlusIcon,
plus_circle: lucide.PlusCircleIcon,
puzzle: lucide.PuzzleIcon,
refresh: lucide.RefreshCwIcon,
rocket: lucide.RocketIcon,
rows_2: lucide.Rows2Icon,
save: lucide.SaveIcon,
search: lucide.SearchIcon,
send_horizontal: lucide.SendHorizonalIcon,
settings: lucide.SettingsIcon,
shield: lucide.ShieldIcon,
shield_check: lucide.ShieldCheckIcon,
shield_off: lucide.ShieldOffIcon,
sparkles: lucide.SparklesIcon,
square_terminal: lucide.SquareTerminalIcon,
sun: lucide.SunIcon,
table: lucide.TableIcon,
text: lucide.FileTextIcon,
trash: lucide.Trash2Icon,
unpin: lucide.PinOffIcon,
update: lucide.RefreshCcwIcon,
upload: lucide.UploadIcon,
variable: lucide.VariableIcon,
wrench: lucide.WrenchIcon,
x: lucide.XIcon,
_unknown: lucide.ShieldAlertIcon,
alert_triangle: AlertTriangleIcon,
archive: ArchiveIcon,
arrow_big_down_dash: ArrowBigDownDashIcon,
arrow_big_left_dash: ArrowBigLeftDashIcon,
arrow_big_right: ArrowBigRightIcon,
arrow_big_right_dash: ArrowBigRightDashIcon,
arrow_big_up_dash: ArrowBigUpDashIcon,
arrow_down: ArrowDownIcon,
arrow_down_to_dot: ArrowDownToDotIcon,
arrow_down_to_line: ArrowDownToLineIcon,
arrow_right_circle: ArrowRightCircleIcon,
arrow_up: ArrowUpIcon,
arrow_up_down: ArrowUpDownIcon,
arrow_up_from_dot: ArrowUpFromDotIcon,
arrow_up_from_line: ArrowUpFromLineIcon,
badge_check: BadgeCheckIcon,
book_open_text: BookOpenText,
box: BoxIcon,
cake: CakeIcon,
chat: MessageSquare,
check: CheckIcon,
check_circle: CheckCircleIcon,
check_square_checked: SquareCheckIcon,
check_square_unchecked: SquareIcon,
chevron_down: ChevronDownIcon,
chevron_left: ChevronLeftIcon,
chevron_right: ChevronRightIcon,
circle_alert: CircleAlertIcon,
circle_dashed: CircleDashedIcon,
circle_dollar_sign: CircleDollarSignIcon,
circle_fading_arrow_up: CircleFadingArrowUpIcon,
clock: ClockIcon,
code: CodeIcon,
columns_2: Columns2Icon,
command: CommandIcon,
cookie: CookieIcon,
copy: CopyIcon,
copy_check: CopyCheck,
corner_right_up: CornerRightUpIcon,
credit_card: CreditCardIcon,
dot: DotIcon,
download: DownloadIcon,
ellipsis: EllipsisIcon,
expand: ExpandIcon,
external_link: ExternalLinkIcon,
eye: EyeIcon,
eye_closed: EyeOffIcon,
file_code: FileCodeIcon,
filter: FilterIcon,
flame: FlameIcon,
flask: FlaskConicalIcon,
folder: FolderIcon,
folder_code: FolderCodeIcon,
folder_cog: FolderCogIcon,
folder_git: FolderGitIcon,
folder_input: FolderInputIcon,
folder_open: FolderOpenIcon,
folder_output: FolderOutputIcon,
folder_symlink: FolderSymlinkIcon,
folder_sync: FolderSyncIcon,
folder_up: FolderUpIcon,
git_branch: GitBranchIcon,
git_branch_plus: GitBranchPlusIcon,
git_commit: GitCommitIcon,
git_commit_vertical: GitCommitVerticalIcon,
git_fork: GitForkIcon,
git_pull_request: GitPullRequestIcon,
grip_vertical: GripVerticalIcon,
hand: HandIcon,
help: CircleHelpIcon,
history: HistoryIcon,
house: HomeIcon,
import: ImportIcon,
info: InfoIcon,
key_round: KeyRoundIcon,
keyboard: KeyboardIcon,
left_panel_hidden: PanelLeftOpenIcon,
left_panel_visible: PanelLeftCloseIcon,
lock: LockIcon,
lock_open: LockOpenIcon,
magic_wand: Wand2Icon,
merge: MergeIcon,
minus: MinusIcon,
minus_circle: MinusCircleIcon,
moon: MoonIcon,
more_vertical: MoreVerticalIcon,
palette: PaletteIcon,
paste: ClipboardPasteIcon,
pencil: PencilIcon,
pin: PinIcon,
plug: Plug,
plus: PlusIcon,
plus_circle: PlusCircleIcon,
puzzle: PuzzleIcon,
refresh: RefreshCwIcon,
rocket: RocketIcon,
rows_2: Rows2Icon,
save: SaveIcon,
search: SearchIcon,
send_horizontal: SendHorizonalIcon,
settings: SettingsIcon,
shield: ShieldIcon,
shield_check: ShieldCheckIcon,
shield_off: ShieldOffIcon,
sparkles: SparklesIcon,
square_terminal: SquareTerminalIcon,
sun: SunIcon,
table: TableIcon,
text: FileTextIcon,
trash: Trash2Icon,
unpin: PinOffIcon,
update: RefreshCcwIcon,
upload: UploadIcon,
variable: VariableIcon,
wrench: WrenchIcon,
x: XIcon,
_unknown: ShieldAlertIcon,
empty: (props: HTMLAttributes<HTMLSpanElement>) => <div {...props} />,
};

View File

@@ -1,4 +1,3 @@
import { EditorSelection } from '@codemirror/state';
import type { EditorView } from '@codemirror/view';
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
@@ -30,7 +29,7 @@ import { Button } from './Button';
import type { DropdownItem } from './Dropdown';
import { Dropdown } from './Dropdown';
import type { EditorProps } from './Editor/Editor';
import { Editor } from './Editor/Editor';
import { Editor } from './Editor/LazyEditor';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
@@ -161,11 +160,12 @@ const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
onFocus?.();
}, [onFocus, readOnly]);
const handleBlur = useCallback(() => {
const handleBlur = useCallback(async () => {
setFocused(false);
// Move selection to the end on blur
const anchor = editorRef.current?.state.doc.length ?? 0;
editorRef.current?.dispatch({
selection: EditorSelection.single(editorRef.current.state.doc.length ),
selection: { anchor, head: anchor },
});
onBlur?.();
}, [onBlur]);

View File

@@ -1,4 +1,16 @@
import type { EditorView } from '@codemirror/view';
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
import {
DndContext,
DragOverlay,
PointerSensor,
pointerWithin,
useDraggable,
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
import classNames from 'classnames';
import {
forwardRef,
@@ -10,13 +22,12 @@ import {
useRef,
useState,
} from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import type { WrappedEnvironmentVariable } from '../../hooks/useEnvironmentVariables';
import { useRandomKey } from '../../hooks/useRandomKey';
import { useToggle } from '../../hooks/useToggle';
import { languageFromContentType } from '../../lib/contentType';
import { showDialog } from '../../lib/dialog';
import { computeSideForDragMove } from '../../lib/dnd';
import { showPrompt } from '../../lib/prompt';
import { DropMarker } from '../DropMarker';
import { SelectFile } from '../SelectFile';
@@ -25,8 +36,8 @@ import { Checkbox } from './Checkbox';
import type { DropdownItem } from './Dropdown';
import { Dropdown } from './Dropdown';
import type { EditorProps } from './Editor/Editor';
import { Editor } from './Editor/Editor';
import type { GenericCompletionConfig } from './Editor/genericCompletion';
import { Editor } from './Editor/LazyEditor';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import type { InputProps } from './Input';
@@ -108,6 +119,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
const [forceFocusNamePairId, setForceFocusNamePairId] = useState<string | null>(null);
const [forceFocusValuePairId, setForceFocusValuePairId] = useState<string | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState<PairWithId | null>(null);
const [pairs, setPairs] = useState<PairWithId[]>([]);
const [showAll, toggleShowAll] = useToggle(false);
// NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If
@@ -158,33 +170,6 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
[onChange],
);
const handleMove = useCallback<PairEditorRowProps['onMove']>(
(id, side) => {
const dragIndex = pairs.findIndex((r) => r.id === id);
setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1);
},
[pairs],
);
const handleEnd = useCallback<PairEditorRowProps['onEnd']>(
(id: string) => {
if (hoveredIndex === null) return;
setHoveredIndex(null);
setPairsAndSave((pairs) => {
const index = pairs.findIndex((p) => p.id === id);
const pair = pairs[index];
if (pair === undefined) return pairs;
const newPairs = pairs.filter((p) => p.id !== id);
if (hoveredIndex > index) newPairs.splice(hoveredIndex - 1, 0, pair);
else newPairs.splice(hoveredIndex, 0, pair);
return newPairs;
});
},
[hoveredIndex, setPairsAndSave],
);
const handleChange = useCallback(
(pair: PairWithId) =>
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
@@ -233,6 +218,55 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
});
}, []);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
// dnd-kit: show the “between rows” marker while hovering
const onDragMove = useCallback(
(e: DragMoveEvent) => {
const overId = e.over?.id as string | undefined;
if (!overId) return setHoveredIndex(null);
const overPair = pairs.find((p) => p.id === overId);
if (overPair == null) return setHoveredIndex(null);
const side = computeSideForDragMove(overPair.id, e);
const overIndex = pairs.findIndex((p) => p.id === overId);
const hoveredIndex = overIndex + (side === 'above' ? 0 : 1);
setHoveredIndex(hoveredIndex);
},
[pairs],
);
const onDragStart = useCallback(
(e: DragStartEvent) => {
const pair = pairs.find((p) => p.id === e.active.id);
setIsDragging(pair ?? null);
},
[pairs],
);
const onDragCancel = useCallback(() => setIsDragging(null), []);
const onDragEnd = useCallback(
(e: DragEndEvent) => {
setIsDragging(null);
setHoveredIndex(null);
const activeId = e.active.id as string | undefined;
const overId = e.over?.id as string | undefined;
if (!activeId || !overId) return;
const from = pairs.findIndex((p) => p.id === activeId);
const baseTo = pairs.findIndex((p) => p.id === overId);
const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo);
if (from !== -1 && to !== -1 && from !== to) {
setPairsAndSave((ps) => arrayMove(ps, from, to > from ? to - 1 : to));
}
},
[pairs, hoveredIndex, setPairsAndSave],
);
return (
<div
className={classNames(
@@ -246,67 +280,82 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
'pt-0.5',
)}
>
{pairs.map((p, i) => {
if (!showAll && i > MAX_INITIAL_PAIRS) return null;
<DndContext
autoScroll
sensors={sensors}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
onDragCancel={onDragCancel}
collisionDetection={pointerWithin}
>
{pairs.map((p, i) => {
if (!showAll && i > MAX_INITIAL_PAIRS) return null;
const isLast = i === pairs.length - 1;
return (
<Fragment key={p.id}>
{hoveredIndex === i && <DropMarker />}
const isLast = i === pairs.length - 1;
return (
<Fragment key={p.id}>
{hoveredIndex === i && <DropMarker />}
<PairEditorRow
allowFileValues={allowFileValues}
allowMultilineValues={allowMultilineValues}
className="py-1"
forcedEnvironmentId={forcedEnvironmentId}
forceFocusNamePairId={forceFocusNamePairId}
forceFocusValuePairId={forceFocusValuePairId}
forceUpdateKey={localForceUpdateKey}
index={i}
isLast={isLast}
isDraggingGlobal={!!isDragging}
nameAutocomplete={nameAutocomplete}
nameAutocompleteFunctions={nameAutocompleteFunctions}
nameAutocompleteVariables={nameAutocompleteVariables}
namePlaceholder={namePlaceholder}
nameValidate={nameValidate}
onChange={handleChange}
onDelete={handleDelete}
onFocusName={handleFocusName}
onFocusValue={handleFocusValue}
pair={p}
stateKey={stateKey}
valueAutocomplete={valueAutocomplete}
valueAutocompleteFunctions={valueAutocompleteFunctions}
valueAutocompleteVariables={valueAutocompleteVariables}
valuePlaceholder={valuePlaceholder}
valueType={valueType}
valueValidate={valueValidate}
/>
</Fragment>
);
})}
{!showAll && pairs.length > MAX_INITIAL_PAIRS && (
<Button onClick={toggleShowAll} variant="border" className="m-2" size="xs">
Show {pairs.length - MAX_INITIAL_PAIRS} More
</Button>
)}
<DragOverlay dropAnimation={null}>
{isDragging && (
<PairEditorRow
allowFileValues={allowFileValues}
allowMultilineValues={allowMultilineValues}
className="py-1"
forcedEnvironmentId={forcedEnvironmentId}
forceFocusNamePairId={forceFocusNamePairId}
forceFocusValuePairId={forceFocusValuePairId}
forceUpdateKey={localForceUpdateKey}
index={i}
isLast={isLast}
nameAutocomplete={nameAutocomplete}
nameAutocompleteFunctions={nameAutocompleteFunctions}
nameAutocompleteVariables={nameAutocompleteVariables}
namePlaceholder={namePlaceholder}
nameValidate={nameValidate}
onChange={handleChange}
onDelete={handleDelete}
onEnd={handleEnd}
onFocusName={handleFocusName}
onFocusValue={handleFocusValue}
onMove={handleMove}
pair={p}
stateKey={stateKey}
valueAutocomplete={valueAutocomplete}
valueAutocompleteFunctions={valueAutocompleteFunctions}
valueAutocompleteVariables={valueAutocompleteVariables}
valuePlaceholder={valuePlaceholder}
valueType={valueType}
valueValidate={valueValidate}
className="opacity-80"
pair={isDragging}
index={0}
stateKey={null}
/>
</Fragment>
);
})}
{!showAll && pairs.length > MAX_INITIAL_PAIRS && (
<Button onClick={toggleShowAll} variant="border" className="m-2" size="xs">
Show {pairs.length - MAX_INITIAL_PAIRS} More
</Button>
)}
)}
</DragOverlay>
</DndContext>
</div>
);
});
enum ItemTypes {
ROW = 'pair-row',
}
type PairEditorRowProps = {
className?: string;
pair: PairWithId;
forceFocusNamePairId?: string | null;
forceFocusValuePairId?: string | null;
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onChange: (pair: PairWithId) => void;
onChange?: (pair: PairWithId) => void;
onDelete?: (pair: PairWithId, focusPrevious: boolean) => void;
onFocusName?: (pair: PairWithId) => void;
onFocusValue?: (pair: PairWithId) => void;
@@ -315,6 +364,7 @@ type PairEditorRowProps = {
disabled?: boolean;
disableDrag?: boolean;
index: number;
isDraggingGlobal?: boolean;
} & Pick<
PairEditorProps,
| 'allowFileValues'
@@ -352,12 +402,11 @@ export function PairEditorRow({
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
isDraggingGlobal,
onChange,
onDelete,
onEnd,
onFocusName,
onFocusValue,
onMove,
pair,
stateKey,
valueAutocomplete,
@@ -367,7 +416,6 @@ export function PairEditorRow({
valueType,
valueValidate,
}: PairEditorRowProps) {
const ref = useRef<HTMLDivElement>(null);
const nameInputRef = useRef<EditorView>(null);
const valueInputRef = useRef<EditorView>(null);
@@ -388,29 +436,29 @@ export function PairEditorRow({
const handleDelete = useCallback(() => onDelete?.(pair, false), [onDelete, pair]);
const handleChangeEnabled = useMemo(
() => (enabled: boolean) => onChange({ ...pair, enabled }),
() => (enabled: boolean) => onChange?.({ ...pair, enabled }),
[onChange, pair],
);
const handleChangeName = useMemo(
() => (name: string) => onChange({ ...pair, name }),
() => (name: string) => onChange?.({ ...pair, name }),
[onChange, pair],
);
const handleChangeValueText = useMemo(
() => (value: string) => onChange({ ...pair, value, isFile: false }),
() => (value: string) => onChange?.({ ...pair, value, isFile: false }),
[onChange, pair],
);
const handleChangeValueFile = useMemo(
() =>
({ filePath }: { filePath: string | null }) =>
onChange({ ...pair, value: filePath ?? '', isFile: true }),
onChange?.({ ...pair, value: filePath ?? '', isFile: true }),
[onChange, pair],
);
const handleChangeValueContentType = useMemo(
() => (contentType: string) => onChange({ ...pair, contentType }),
() => (contentType: string) => onChange?.({ ...pair, contentType }),
[onChange, pair],
);
@@ -448,30 +496,8 @@ export function PairEditorRow({
[allowMultilineValues, handleDelete, handleEditMultiLineValue],
);
const [, connectDrop] = useDrop<Pair>(
{
accept: ItemTypes.ROW,
hover: (_, monitor) => {
if (!ref.current) return;
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
onMove(pair.id, hoverClientY < hoverMiddleY ? 'above' : 'below');
},
},
[onMove],
);
const [, connectDrag] = useDrag(
{
type: ItemTypes.ROW,
item: () => pair,
collect: (m) => ({ isDragging: m.isDragging() }),
end: () => onEnd(pair.id),
},
[pair, onEnd],
);
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: pair.id });
const { setNodeRef: setDroppableRef } = useDroppable({ id: pair.id });
// Filter out the current pair name
const valueAutocompleteVariablesFiltered = useMemo<EditorProps['autocompleteVariables']>(() => {
@@ -482,12 +508,17 @@ export function PairEditorRow({
}
}, [pair.name, valueAutocompleteVariables]);
connectDrag(ref);
connectDrop(ref);
const handleSetRef = useCallback(
(n: HTMLDivElement | null) => {
setDraggableRef(n);
setDroppableRef(n);
},
[setDraggableRef, setDroppableRef],
);
return (
<div
ref={ref}
ref={handleSetRef}
className={classNames(
className,
'group grid grid-cols-[auto_auto_minmax(0,1fr)_auto]',
@@ -505,6 +536,8 @@ export function PairEditorRow({
/>
{!isLast && !disableDrag ? (
<div
{...attributes}
{...listeners}
className={classNames(
'py-2 h-7 w-4 flex items-center',
'justify-center opacity-0 group-hover:opacity-70',
@@ -529,6 +562,7 @@ export function PairEditorRow({
hideLabel
size="sm"
containerClassName={classNames(isLast && 'border-dashed')}
className={classNames(isDraggingGlobal && 'pointer-events-none')}
label="Name"
name={`name[${index}]`}
onFocus={handleFocusName}
@@ -541,13 +575,13 @@ export function PairEditorRow({
stateKey={`name.${pair.id}.${stateKey}`}
disabled={disabled}
wrapLines={false}
readOnly={pair.readOnlyName}
readOnly={pair.readOnlyName || isDraggingGlobal}
size="sm"
required={!isLast && !!pair.enabled && !!pair.value}
validate={nameValidate}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey}
containerClassName={classNames(isLast && 'border-dashed')}
containerClassName={classNames('bg-surface', isLast && 'border-dashed')}
defaultValue={pair.name}
label="Name"
name={`name[${index}]`}
@@ -578,6 +612,7 @@ export function PairEditorRow({
containerClassName={classNames(isLast && 'border-dashed')}
label="Value"
name={`value[${index}]`}
className={classNames(isDraggingGlobal && 'pointer-events-none')}
onFocus={handleFocusValue}
placeholder={valuePlaceholder ?? 'value'}
/>
@@ -599,7 +634,8 @@ export function PairEditorRow({
wrapLines={false}
size="sm"
disabled={disabled}
containerClassName={classNames(isLast && 'border-dashed')}
readOnly={isDraggingGlobal}
containerClassName={classNames('bg-surface', isLast && 'border-dashed')}
validate={valueValidate}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey}

View File

@@ -73,7 +73,6 @@ export function Tabs({
className={classNames(
className,
'tabs-container',
'transform-gpu',
'h-full grid',
layout === 'horizontal' && 'grid-rows-1 grid-cols-[auto_minmax(0,1fr)]',
layout === 'vertical' && 'grid-rows-[auto_minmax(0,1fr)] grid-cols-1',

View File

@@ -1,13 +1,22 @@
import classNames from 'classnames';
import type { CSSProperties, KeyboardEvent, ReactNode } from 'react';
import React, { useRef, useState } from 'react';
import type {
CSSProperties,
KeyboardEvent,
ReactNode} from 'react';
import React, {
lazy,
Suspense,
useRef,
useState,
} from 'react';
import { generateId } from '../../lib/generateId';
import { Portal } from '../Portal';
const Portal = lazy(() => import('../Portal').then((m) => ({ default: m.Portal })));
export interface TooltipProps {
children: ReactNode;
content: ReactNode;
tabIndex?: number,
tabIndex?: number;
size?: 'md' | 'lg';
}
@@ -66,7 +75,7 @@ export function Tooltip({ children, content, tabIndex, size = 'md' }: TooltipPro
const id = useRef(`tooltip-${generateId()}`);
return (
<>
<Suspense>
<Portal name="tooltip">
<div
ref={tooltipRef}
@@ -105,7 +114,7 @@ export function Tooltip({ children, content, tabIndex, size = 'md' }: TooltipPro
>
{children}
</span>
</>
</Suspense>
);
}

View File

@@ -1,63 +0,0 @@
// AutoScrollWhileDragging.tsx
import { useEffect, useRef } from 'react';
import { useDragLayer } from 'react-dnd';
type Props = {
container: HTMLElement | null | undefined;
edgeDistance?: number;
maxSpeedPerFrame?: number;
};
export function AutoScrollWhileDragging({
container,
edgeDistance = 30,
maxSpeedPerFrame = 6,
}: Props) {
const rafId = useRef<number | null>(null);
const { isDragging, pointer } = useDragLayer((monitor) => ({
isDragging: monitor.isDragging(),
pointer: monitor.getClientOffset(), // { x, y } | null
}));
useEffect(() => {
if (!container || !isDragging) {
if (rafId.current != null) cancelAnimationFrame(rafId.current);
rafId.current = null;
return;
}
const tick = () => {
if (!container || !isDragging || !pointer) return;
const rect = container.getBoundingClientRect();
const y = pointer.y;
// Compute vertical speed based on proximity to edges
let dy = 0;
if (y < rect.top + edgeDistance) {
const t = (rect.top + edgeDistance - y) / edgeDistance; // 0..1
dy = -Math.min(maxSpeedPerFrame, Math.ceil(t * maxSpeedPerFrame));
} else if (y > rect.bottom - edgeDistance) {
const t = (y - (rect.bottom - edgeDistance)) / edgeDistance; // 0..1
dy = Math.min(maxSpeedPerFrame, Math.ceil(t * maxSpeedPerFrame));
}
if (dy !== 0) {
// Only scroll if theres more content in that direction
const prev = container.scrollTop;
container.scrollTop = prev + dy;
}
rafId.current = requestAnimationFrame(tick);
};
rafId.current = requestAnimationFrame(tick);
return () => {
if (rafId.current != null) cancelAnimationFrame(rafId.current);
rafId.current = null;
};
}, [container, isDragging, pointer, edgeDistance, maxSpeedPerFrame]);
return null;
}

View File

@@ -14,6 +14,7 @@ import { forwardRef, memo, useCallback, useImperativeHandle, useMemo, useRef } f
import { useKey, useKeyPressEvent } from 'react-use';
import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey';
import { useHotKey } from '../../../hooks/useHotKey';
import { computeSideForDragMove } from '../../../lib/dnd';
import { jotaiStore } from '../../../lib/jotai';
import type { ContextMenuProps } from '../Dropdown';
import {
@@ -24,7 +25,7 @@ import {
selectedIdsFamily,
} from './atoms';
import type { SelectableTreeNode, TreeNode } from './common';
import { computeSideForDragMove, equalSubtree, getSelectedItems, hasAncestor } from './common';
import { equalSubtree, getSelectedItems, hasAncestor } from './common';
import { TreeDragOverlay } from './TreeDragOverlay';
import type { TreeItemProps } from './TreeItem';
import type { TreeItemListProps } from './TreeItemList';
@@ -255,7 +256,7 @@ function TreeInner<T extends { id: string }>(
}
const node = selectableItem.node;
const side = computeSideForDragMove(node, e);
const side = computeSideForDragMove(node.item.id, e);
const item = node.item;
let hoveredParent = node.parent;

View File

@@ -5,13 +5,13 @@ import { useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import type { MouseEvent, PointerEvent } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { computeSideForDragMove } from '../../../lib/dnd';
import { jotaiStore } from '../../../lib/jotai';
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
import { ContextMenu } from '../Dropdown';
import { Icon } from '../Icon';
import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from './atoms';
import type { TreeNode } from './common';
import { computeSideForDragMove } from './common';
import type { TreeProps } from './Tree';
import { TreeIndentGuide } from './TreeIndentGuide';
@@ -161,7 +161,7 @@ function TreeItem_<T extends { id: string }>({
clearDropHover();
},
onDragMove(e: DragMoveEvent) {
const side = computeSideForDragMove(node, e);
const side = computeSideForDragMove(node.item.id, e);
const isFolder = node.children != null;
const hasChildren = (node.children?.length ?? 0) > 0;
const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id }));

View File

@@ -1,8 +1,7 @@
import type { DragMoveEvent } from '@dnd-kit/core';
import { jotaiStore } from '../../../lib/jotai';
import { selectedIdsFamily } from './atoms';
export interface TreeNode<T extends { id: string } > {
export interface TreeNode<T extends { id: string }> {
children?: TreeNode<T>[];
item: T;
parent: TreeNode<T> | null;
@@ -48,25 +47,3 @@ export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancesto
// Check parents recursively
return hasAncestor(node.parent, ancestorId);
}
export function computeSideForDragMove<T extends { id: string }>(
node: TreeNode<T>,
e: DragMoveEvent,
): 'above' | 'below' | null {
if (e.over == null || e.over.id !== node.item.id) {
return null;
}
if (e.active.rect.current.initial == null) return null;
const overRect = e.over.rect;
const activeTop =
e.active.rect.current.translated?.top ?? e.active.rect.current.initial.top + e.delta.y;
const pointerY = activeTop + e.active.rect.current.initial.height / 2;
const hoverTop = overRect.top;
const hoverBottom = overRect.bottom;
const hoverMiddleY = (hoverBottom - hoverTop) / 2;
const hoverClientY = pointerY - hoverTop;
return hoverClientY < hoverMiddleY ? 'above' : 'below';
}