Click env var to edit AND improve input/editor ref handling

This commit is contained in:
Gregory Schier
2025-11-01 08:39:07 -07:00
parent 2bcf67aaa6
commit 6ad4e7bbb5
19 changed files with 372 additions and 302 deletions

View File

@@ -2,15 +2,7 @@ import type { EditorView } from '@codemirror/view';
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createFastMutation } from '../../hooks/useFastMutation';
import { useIsEncryptionEnabled } from '../../hooks/useIsEncryptionEnabled';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
@@ -80,6 +72,7 @@ export type InputProps = Pick<
type?: 'text' | 'password';
validate?: boolean | ((v: string) => boolean);
wrapLines?: boolean;
setRef?: (h: InputHandle | null) => void;
};
export interface InputHandle {
@@ -90,80 +83,86 @@ export interface InputHandle {
dispatch: EditorView['dispatch'];
}
export const Input = forwardRef<InputHandle, InputProps>(function Input({ type, ...props }, ref) {
export function Input({ type, ...props }: InputProps) {
// If it's a password and template functions are supported (ie. secure(...)) then
// use the encrypted input component.
if (type === 'password' && props.autocompleteFunctions) {
return <EncryptionInput {...props} />;
} else {
return <BaseInput ref={ref} type={type} {...props} />;
return <BaseInput type={type} {...props} />;
}
});
}
const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
{
className,
containerClassName,
defaultValue,
disableObscureToggle,
disabled,
forceUpdateKey,
fullHeight,
help,
hideLabel,
inputWrapperClassName,
label,
labelClassName,
labelPosition = 'top',
leftSlot,
multiLine,
onBlur,
onChange,
onFocus,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
required,
rightSlot,
size = 'md',
stateKey,
tint,
type = 'text',
validate,
wrapLines,
...props
}: InputProps,
ref,
) {
function BaseInput({
className,
containerClassName,
defaultValue,
disableObscureToggle,
disabled,
forceUpdateKey,
fullHeight,
help,
hideLabel,
inputWrapperClassName,
label,
labelClassName,
labelPosition = 'top',
leftSlot,
multiLine,
onBlur,
onChange,
onFocus,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
required,
rightSlot,
size = 'md',
stateKey,
tint,
type = 'text',
validate,
wrapLines,
setRef,
...props
}: InputProps) {
const [focused, setFocused] = useState(false);
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
const editorRef = useRef<EditorView | null>(null);
const inputHandle = useMemo<InputHandle>(
() => ({
focus: () => {
editorRef.current?.focus();
},
isFocused: () => editorRef.current?.hasFocus ?? false,
value: () => editorRef.current?.state.doc.toString() ?? '',
dispatch: (...args) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editorRef.current?.dispatch(...(args as any));
},
selectAll() {
const head = editorRef.current?.state.doc.length ?? 0;
editorRef.current?.dispatch({
selection: { anchor: 0, head },
});
editorRef.current?.focus();
},
}),
[],
);
const initEditorRef = useCallback(
(cm: EditorView | null) => {
editorRef.current = cm;
if (cm == null) {
setRef?.(null);
return;
}
const handle: InputHandle = {
focus: () => {
cm.focus();
cm.dispatch({ selection: { anchor: cm.state.doc.length, head: cm.state.doc.length } });
},
isFocused: () => cm.hasFocus ?? false,
value: () => cm.state.doc.toString() ?? '',
dispatch: (...args) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cm.dispatch(...(args as any));
},
selectAll() {
cm.focus();
useImperativeHandle(ref, (): InputHandle => inputHandle, [inputHandle]);
cm.dispatch({
selection: { anchor: 0, head: cm.state.doc.length },
});
},
};
setRef?.(handle);
},
[setRef],
);
const lastWindowFocus = useRef<number>(0);
useEffect(() => {
@@ -300,7 +299,7 @@ const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
)}
>
<Editor
ref={editorRef}
setRef={initEditorRef}
id={id.current}
hideGutter
singleLine={!multiLine}
@@ -351,7 +350,7 @@ const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
</HStack>
</div>
);
});
}
function validateRequire(v: string) {
return v.length > 0;
@@ -365,8 +364,9 @@ function EncryptionInput({
autocompleteFunctions,
autocompleteVariables,
forceUpdateKey: ogForceUpdateKey,
setRef,
...props
}: Omit<InputProps, 'type'>) {
}: InputProps) {
const isEncryptionEnabled = useIsEncryptionEnabled();
const [state, setState] = useStateWithDeps<{
fieldType: PasswordFieldType;
@@ -374,11 +374,19 @@ function EncryptionInput({
security: ReturnType<typeof analyzeTemplate> | null;
obscured: boolean;
error: string | null;
}>({ fieldType: 'text', value: null, security: null, obscured: true, error: null }, [
ogForceUpdateKey,
]);
}>(
{
fieldType: isEncryptionEnabled ? 'encrypted' : 'text',
value: null,
security: null,
obscured: true,
error: null,
},
[ogForceUpdateKey],
);
const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === null}`;
const inputRef = useRef<InputHandle>(null);
useEffect(() => {
if (state.value != null) {
@@ -392,6 +400,9 @@ function EncryptionInput({
templateToInsecure.mutate(defaultValue ?? '', {
onSuccess: (value) => {
setState({ fieldType: 'encrypted', security, value, obscured: true, error: null });
// We're calling this here because we want the input to be fully initialized so the caller
// can do stuff like change the selection.
setRef?.(inputRef.current);
},
onError: (value) => {
setState({
@@ -406,6 +417,7 @@ function EncryptionInput({
} else if (isEncryptionEnabled && !defaultValue) {
// Default to encrypted field for new encrypted inputs
setState({ fieldType: 'encrypted', security, value: '', obscured: true, error: null });
setRef?.(inputRef.current);
} else if (isEncryptionEnabled) {
// Don't obscure plain text when encryption is enabled
setState({
@@ -424,8 +436,9 @@ function EncryptionInput({
obscured: true,
error: null,
});
setRef?.(inputRef.current);
}
}, [defaultValue, isEncryptionEnabled, setState, state.value]);
}, [defaultValue, isEncryptionEnabled, setRef, setState, state.value]);
const handleChange = useCallback(
(value: string, fieldType: PasswordFieldType) => {
@@ -454,6 +467,10 @@ function EncryptionInput({
[handleChange, state],
);
const handleSetInputRef = useCallback((h: InputHandle | null) => {
inputRef.current = h;
}, []);
const handleFieldTypeChange = useCallback(
(newFieldType: PasswordFieldType) => {
const { value, fieldType } = state;
@@ -563,6 +580,7 @@ function EncryptionInput({
return (
<BaseInput
setRef={handleSetInputRef}
disableObscureToggle
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}