mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-22 16:48:23 +01:00
suggestions modal (#260)
This commit is contained in:
parent
af3bc7d249
commit
c1684d28d1
@ -17,6 +17,7 @@
|
||||
}
|
||||
|
||||
.block-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
@ -184,7 +185,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.block-frame-div-url {
|
||||
.block-frame-div-url,
|
||||
.block-frame-div-search {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
input {
|
||||
@ -255,14 +257,16 @@
|
||||
}
|
||||
|
||||
&.is-layoutmode .block-mask-inner {
|
||||
margin-top: 35px; // TODO fix this magic
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
height: 100%;
|
||||
height: calc(100% - 35px);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.bignum {
|
||||
margin-top: -15%;
|
||||
font-size: 60px;
|
||||
font-weight: bold;
|
||||
opacity: 0.7;
|
||||
|
85
frontend/app/element/input.less
Normal file
85
frontend/app/element/input.less
Normal file
@ -0,0 +1,85 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
min-height: 32px;
|
||||
min-width: 200px;
|
||||
gap: 6px;
|
||||
border: 2px solid var(--form-element-border-color);
|
||||
background: var(--form-element-bg-color);
|
||||
|
||||
&:hover {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
&.focused {
|
||||
border-color: var(--form-element-primary-color);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--form-element-error-color);
|
||||
}
|
||||
|
||||
&-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
--inner-padding: 5px 0 5px 16px;
|
||||
|
||||
&-label {
|
||||
padding: var(--inner-padding);
|
||||
margin-bottom: -10px;
|
||||
font-size: 12.5px;
|
||||
transition: all 0.3s;
|
||||
color: var(--form-element-label-color);
|
||||
line-height: 10px;
|
||||
user-select: none;
|
||||
|
||||
&.float {
|
||||
font-size: 10px;
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
&.offset-left {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
padding: var(--inner-padding);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
color: var(--form-element-text-color);
|
||||
line-height: 20px;
|
||||
|
||||
&.offset-left {
|
||||
padding: 5px 16px 5px 0;
|
||||
}
|
||||
|
||||
&:placeholder-shown {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.no-label {
|
||||
height: 34px;
|
||||
|
||||
input {
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
167
frontend/app/element/input.tsx
Normal file
167
frontend/app/element/input.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import { clsx } from "clsx";
|
||||
import React, { forwardRef, useEffect, useRef, useState } from "react";
|
||||
|
||||
import "./input.less";
|
||||
|
||||
interface InputDecorationProps {
|
||||
startDecoration?: React.ReactNode;
|
||||
endDecoration?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface InputProps {
|
||||
label?: string;
|
||||
value?: string;
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
decoration?: InputDecorationProps;
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
isNumber?: boolean;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLDivElement, InputProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
placeholder,
|
||||
defaultValue = "",
|
||||
decoration,
|
||||
required,
|
||||
maxLength,
|
||||
autoFocus,
|
||||
disabled,
|
||||
isNumber,
|
||||
}: InputProps,
|
||||
ref
|
||||
) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||
const [error, setError] = useState(false);
|
||||
const [hasContent, setHasContent] = useState(Boolean(value || defaultValue));
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== undefined) {
|
||||
setFocused(Boolean(value));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleComponentFocus = () => {
|
||||
if (inputRef.current && !inputRef.current.contains(document.activeElement)) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleComponentBlur = () => {
|
||||
if (inputRef.current?.contains(document.activeElement)) {
|
||||
inputRef.current.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setFocused(true);
|
||||
onFocus && onFocus();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (inputRef.current) {
|
||||
const inputValue = inputRef.current.value;
|
||||
if (required && !inputValue) {
|
||||
setError(true);
|
||||
setFocused(false);
|
||||
} else {
|
||||
setError(false);
|
||||
setFocused(false);
|
||||
}
|
||||
}
|
||||
onBlur && onBlur();
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
|
||||
if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (required && !inputValue) {
|
||||
setError(true);
|
||||
setHasContent(false);
|
||||
} else {
|
||||
setError(false);
|
||||
setHasContent(Boolean(inputValue));
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
setInternalValue(inputValue);
|
||||
}
|
||||
|
||||
onChange && onChange(inputValue);
|
||||
};
|
||||
|
||||
const inputValue = value ?? internalValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx("input", className, {
|
||||
focused: focused,
|
||||
error: error,
|
||||
disabled: disabled,
|
||||
"no-label": !label,
|
||||
})}
|
||||
onFocus={handleComponentFocus}
|
||||
onBlur={handleComponentBlur}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{decoration?.startDecoration && <>{decoration.startDecoration}</>}
|
||||
<div className="input-inner">
|
||||
{label && (
|
||||
<label
|
||||
className={clsx("input-inner-label", {
|
||||
float: hasContent || focused || placeholder,
|
||||
"offset-left": decoration?.startDecoration,
|
||||
})}
|
||||
htmlFor={label}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
className={clsx("input-inner-input", {
|
||||
"offset-left": decoration?.startDecoration,
|
||||
})}
|
||||
ref={inputRef}
|
||||
id={label}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{decoration?.endDecoration && <>{decoration.endDecoration}</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export { Input };
|
||||
export type { InputDecorationProps, InputProps };
|
21
frontend/app/element/inputdecoration.less
Normal file
21
frontend/app/element/inputdecoration.less
Normal file
@ -0,0 +1,21 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.input-decoration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
i {
|
||||
font-size: 13px;
|
||||
color: var(--form-element-icon-color);
|
||||
}
|
||||
}
|
||||
|
||||
.input-decoration.start-position {
|
||||
margin: 0 4px 0 16px;
|
||||
}
|
||||
|
||||
.input-decoration.end-position {
|
||||
margin: 0 16px 0 8px;
|
||||
}
|
28
frontend/app/element/inputdecoration.tsx
Normal file
28
frontend/app/element/inputdecoration.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import * as React from "react";
|
||||
|
||||
import "./inputdecoration.less";
|
||||
|
||||
interface InputDecorationProps {
|
||||
position?: "start" | "end";
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const InputDecoration = (props: InputDecorationProps) => {
|
||||
const { children, position = "end" } = props;
|
||||
return (
|
||||
<div
|
||||
className={clsx("input-decoration", {
|
||||
"start-position": position === "start",
|
||||
"end-position": position === "end",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { InputDecoration };
|
91
frontend/app/hook/useDimensions.tsx
Normal file
91
frontend/app/hook/useDimensions.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import debounce from "lodash.debounce";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const useDimensions = (ref: React.RefObject<HTMLElement>, delay = 0) => {
|
||||
const [dimensions, setDimensions] = useState<{
|
||||
height: number | null;
|
||||
width: number | null;
|
||||
widthDirection?: string;
|
||||
heightDirection?: string;
|
||||
}>({
|
||||
height: null,
|
||||
width: null,
|
||||
});
|
||||
|
||||
const previousDimensions = useRef<{ height: number | null; width: number | null }>({
|
||||
height: null,
|
||||
width: null,
|
||||
});
|
||||
|
||||
const updateDimensions = useCallback(() => {
|
||||
if (ref.current) {
|
||||
const element = ref.current;
|
||||
const style = window.getComputedStyle(element);
|
||||
const paddingTop = parseFloat(style.paddingTop);
|
||||
const paddingBottom = parseFloat(style.paddingBottom);
|
||||
const paddingLeft = parseFloat(style.paddingLeft);
|
||||
const paddingRight = parseFloat(style.paddingRight);
|
||||
const marginTop = parseFloat(style.marginTop);
|
||||
const marginBottom = parseFloat(style.marginBottom);
|
||||
const marginLeft = parseFloat(style.marginLeft);
|
||||
const marginRight = parseFloat(style.marginRight);
|
||||
|
||||
const parentHeight = element.clientHeight - paddingTop - paddingBottom - marginTop - marginBottom;
|
||||
const parentWidth = element.clientWidth - paddingLeft - paddingRight - marginLeft - marginRight;
|
||||
|
||||
let widthDirection = "";
|
||||
let heightDirection = "";
|
||||
|
||||
if (previousDimensions.current.width !== null && previousDimensions.current.height !== null) {
|
||||
if (parentWidth > previousDimensions.current.width) {
|
||||
widthDirection = "expanding";
|
||||
} else if (parentWidth < previousDimensions.current.width) {
|
||||
widthDirection = "shrinking";
|
||||
} else {
|
||||
widthDirection = "unchanged";
|
||||
}
|
||||
|
||||
if (parentHeight > previousDimensions.current.height) {
|
||||
heightDirection = "expanding";
|
||||
} else if (parentHeight < previousDimensions.current.height) {
|
||||
heightDirection = "shrinking";
|
||||
} else {
|
||||
heightDirection = "unchanged";
|
||||
}
|
||||
}
|
||||
|
||||
previousDimensions.current = { height: parentHeight, width: parentWidth };
|
||||
|
||||
setDimensions({ height: parentHeight, width: parentWidth, widthDirection, heightDirection });
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
const fUpdateDimensions = useCallback(delay > 0 ? debounce(updateDimensions, delay) : updateDimensions, [
|
||||
updateDimensions,
|
||||
delay,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fUpdateDimensions();
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
resizeObserver.observe(ref.current);
|
||||
fUpdateDimensions();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (ref.current) {
|
||||
resizeObserver.unobserve(ref.current);
|
||||
}
|
||||
if (delay > 0) {
|
||||
fUpdateDimensions.cancel();
|
||||
}
|
||||
};
|
||||
}, [fUpdateDimensions]);
|
||||
|
||||
return dimensions;
|
||||
};
|
||||
|
||||
export { useDimensions };
|
@ -14,7 +14,7 @@ const AboutModal = ({}: AboutModalProps) => {
|
||||
const currentDate = new Date();
|
||||
|
||||
return (
|
||||
<Modal className="about-modal" title="About" onClose={() => modalsModel.popModal()}>
|
||||
<Modal className="about-modal" onClose={() => modalsModel.popModal()}>
|
||||
<div className="section-wrapper">
|
||||
<div className="section logo-section">
|
||||
<Logo />
|
||||
|
@ -29,42 +29,29 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 32px;
|
||||
padding: 24px 16px 16px;
|
||||
border-radius: 8px;
|
||||
border: 0.5px solid var(--modal-border-color);
|
||||
background: var(--modal-bg-color);
|
||||
box-shadow: 0px 8px 32px 0px rgba(0, 0, 0, 0.25);
|
||||
|
||||
.header-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
.modal-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
user-select: none;
|
||||
|
||||
.modal-title {
|
||||
color: var(--main-text-color);
|
||||
font-size: var(--title-font-size);
|
||||
}
|
||||
|
||||
button {
|
||||
.modal-close-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
padding: 8px 16px;
|
||||
padding: 8px 12px;
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
.modal-content {
|
||||
width: 100%;
|
||||
padding: 0px 20px;
|
||||
|
@ -7,22 +7,6 @@ import ReactDOM from "react-dom";
|
||||
|
||||
import "./modal.less";
|
||||
|
||||
interface ModalHeaderProps {
|
||||
description?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const ModalHeader = ({ onClose, description }: ModalHeaderProps) => (
|
||||
<header className="modal-header">
|
||||
{description && <p>{description}</p>}
|
||||
{onClose && (
|
||||
<Button className="secondary ghost" onClick={onClose} title="Close (ESC)">
|
||||
<i className="fa-sharp fa-solid fa-xmark"></i>
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
|
||||
interface ModalContentProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@ -56,7 +40,6 @@ const ModalFooter = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok" }:
|
||||
};
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
description?: string;
|
||||
okLabel?: string;
|
||||
@ -71,7 +54,6 @@ interface ModalProps {
|
||||
const Modal = ({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
cancelLabel,
|
||||
okLabel,
|
||||
@ -90,8 +72,10 @@ const Modal = ({
|
||||
<div className="modal-wrapper">
|
||||
{renderBackdrop(onClickBackdrop)}
|
||||
<div className={clsx(`modal`, className)}>
|
||||
<div className="header-content-wrapper">
|
||||
<ModalHeader onClose={onClose} description={description} />
|
||||
<Button className="secondary ghost modal-close-btn" onClick={onClose} title="Close (ESC)">
|
||||
<i className="fa-sharp fa-solid fa-xmark"></i>
|
||||
</Button>
|
||||
<div className="content-wrapper">
|
||||
<ModalContent>{children}</ModalContent>
|
||||
</div>
|
||||
{renderFooter() && (
|
||||
@ -123,7 +107,6 @@ const FlexiModal = ({ children, className, onClickBackdrop }: FlexiModalProps) =
|
||||
return ReactDOM.createPortal(renderModal(), document.getElementById("main"));
|
||||
};
|
||||
|
||||
FlexiModal.Header = ModalHeader;
|
||||
FlexiModal.Content = ModalContent;
|
||||
FlexiModal.Footer = ModalFooter;
|
||||
|
||||
|
83
frontend/app/modals/typeaheadmodal.less
Normal file
83
frontend/app/modals/typeaheadmodal.less
Normal file
@ -0,0 +1,83 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.type-ahead-modal-wrapper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: var(--zindex-modal-wrapper);
|
||||
|
||||
.type-ahead-modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(21, 23, 21, 0.7);
|
||||
z-index: var(--zindex-modal-backdrop);
|
||||
}
|
||||
}
|
||||
|
||||
.type-ahead-modal {
|
||||
position: relative;
|
||||
z-index: var(--zindex-modal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 0.5px solid var(--modal-border-color);
|
||||
background: var(--modal-bg-color);
|
||||
box-shadow: 0px 8px 32px 0px rgba(0, 0, 0, 0.25);
|
||||
|
||||
.modal-close-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
padding: 8px 12px;
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 390px;
|
||||
|
||||
.label {
|
||||
opacity: 0.5;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.suggestions-wrapper {
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
.suggestion {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
padding: 7px 10px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--highlight-bg-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
202
frontend/app/modals/typeaheadmodal.tsx
Normal file
202
frontend/app/modals/typeaheadmodal.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import { Input } from "@/app/element/input";
|
||||
import { InputDecoration } from "@/app/element/inputdecoration";
|
||||
import { useDimensions } from "@/app/hook/useDimensions";
|
||||
import clsx from "clsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import "./typeaheadmodal.less";
|
||||
|
||||
const dummy: SuggestionType[] = [
|
||||
{
|
||||
label: "Apple",
|
||||
value: "apple",
|
||||
},
|
||||
{
|
||||
label: "Banana",
|
||||
value: "banana",
|
||||
},
|
||||
{
|
||||
label: "Cherry",
|
||||
value: "cherry",
|
||||
},
|
||||
{
|
||||
label: "Date",
|
||||
value: "date",
|
||||
},
|
||||
{
|
||||
label: "Elderberry",
|
||||
value: "elderberry",
|
||||
},
|
||||
{
|
||||
label: "Apple",
|
||||
value: "apple",
|
||||
},
|
||||
{
|
||||
label: "Banana",
|
||||
value: "banana",
|
||||
},
|
||||
{
|
||||
label: "Cherry",
|
||||
value: "cherry",
|
||||
},
|
||||
{
|
||||
label: "Date",
|
||||
value: "date",
|
||||
},
|
||||
{
|
||||
label: "Elderberry",
|
||||
value: "elderberry",
|
||||
},
|
||||
{
|
||||
label: "Apple",
|
||||
value: "apple",
|
||||
},
|
||||
{
|
||||
label: "Banana",
|
||||
value: "banana",
|
||||
},
|
||||
{
|
||||
label: "Cherry",
|
||||
value: "cherry",
|
||||
},
|
||||
{
|
||||
label: "Date",
|
||||
value: "date",
|
||||
},
|
||||
{
|
||||
label: "Elderberry",
|
||||
value: "elderberry",
|
||||
},
|
||||
];
|
||||
|
||||
type SuggestionType = {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
interface SuggestionsProps {
|
||||
suggestions?: SuggestionType[];
|
||||
onSelect?: (_: string) => void;
|
||||
}
|
||||
|
||||
function Suggestions({ suggestions, onSelect }: SuggestionsProps) {
|
||||
const suggestionsWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={suggestionsWrapperRef}
|
||||
className="suggestions-wrapper"
|
||||
style={{ marginTop: suggestions?.length > 0 ? "8px" : "0" }}
|
||||
>
|
||||
{suggestions?.map((suggestion, index) => (
|
||||
<div className="suggestion" key={index} onClick={() => onSelect(suggestion.value)}>
|
||||
{suggestion.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TypeAheadModalProps {
|
||||
anchor: React.MutableRefObject<HTMLDivElement>;
|
||||
suggestions?: SuggestionType[];
|
||||
label?: string;
|
||||
className?: string;
|
||||
onSelect?: (_: string) => void;
|
||||
onChange?: (_: string) => void;
|
||||
onClickBackdrop?: () => void;
|
||||
onKeyDown?: (_) => void;
|
||||
}
|
||||
|
||||
const TypeAheadModal = ({
|
||||
className,
|
||||
suggestions = dummy,
|
||||
label,
|
||||
anchor,
|
||||
onChange,
|
||||
onSelect,
|
||||
onClickBackdrop,
|
||||
onKeyDown,
|
||||
}: TypeAheadModalProps) => {
|
||||
const { width, height } = useDimensions(anchor);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
const [suggestionsHeight, setSuggestionsHeight] = useState<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (modalRef.current && inputRef.current) {
|
||||
const modalHeight = modalRef.current.getBoundingClientRect().height;
|
||||
const inputHeight = inputRef.current.getBoundingClientRect().height;
|
||||
|
||||
// Get the padding value (assuming padding is uniform on all sides)
|
||||
const padding = 16 * 2; // 16px top + 16px bottom
|
||||
|
||||
// Subtract the input height and padding from the modal height
|
||||
setSuggestionsHeight(modalHeight - inputHeight - padding);
|
||||
}
|
||||
}, [width, height]);
|
||||
|
||||
const renderBackdrop = (onClick) => <div className="type-ahead-modal-backdrop" onClick={onClick}></div>;
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
onKeyDown && onKeyDown(e);
|
||||
};
|
||||
|
||||
const handleChange = (value) => {
|
||||
onChange && onChange(value);
|
||||
};
|
||||
|
||||
const handleSelect = (value) => {
|
||||
onSelect && onSelect(value);
|
||||
};
|
||||
|
||||
const renderModal = () => (
|
||||
<div className="type-ahead-modal-wrapper" onKeyDown={handleKeyDown}>
|
||||
{renderBackdrop(onClickBackdrop)}
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={clsx("type-ahead-modal", className)}
|
||||
style={{
|
||||
width: width * 0.6,
|
||||
maxHeight: height * 0.8,
|
||||
}}
|
||||
>
|
||||
<div className="content-wrapper">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
onChange={handleChange}
|
||||
autoFocus
|
||||
decoration={{
|
||||
startDecoration: (
|
||||
<InputDecoration position="start">
|
||||
<div className="label">{label}</div>
|
||||
</InputDecoration>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="suggestions-wrapper"
|
||||
style={{
|
||||
marginTop: suggestions?.length > 0 ? "8px" : "0",
|
||||
height: suggestionsHeight,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<Suggestions suggestions={suggestions} onSelect={handleSelect} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (anchor.current == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(renderModal(), anchor.current);
|
||||
};
|
||||
|
||||
export { TypeAheadModal };
|
||||
export type { SuggestionType };
|
@ -108,12 +108,9 @@ const UserInputModal = (userInputRequest: UserInputRequest) => {
|
||||
}, [countdown]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={userInputRequest.title + ` (${countdown}s)`}
|
||||
onOk={() => handleSubmit()}
|
||||
onCancel={() => handleSendCancel()}
|
||||
>
|
||||
<Modal onOk={() => handleSubmit()} onCancel={() => handleSendCancel()}>
|
||||
<div className="userinput-body">
|
||||
{userInputRequest.title + ` (${countdown}s)`}
|
||||
{queryText}
|
||||
{inputBox}
|
||||
</div>
|
||||
|
@ -123,6 +123,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
||||
// do nothing
|
||||
}
|
||||
const reducedMotionPreferenceAtom = jotai.atom((get) => get(settingsConfigAtom).window.reducedmotion);
|
||||
const typeAheadModalAtom = jotai.atom({});
|
||||
atoms = {
|
||||
// initialized in wave.ts (will not be null inside of application)
|
||||
windowId: windowIdAtom,
|
||||
@ -138,6 +139,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
||||
controlShiftDelayAtom,
|
||||
updaterStatusAtom,
|
||||
reducedMotionPreferenceAtom,
|
||||
typeAheadModalAtom,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -79,4 +79,13 @@
|
||||
--button-secondary-color: rgba(255, 255, 255, 0.1);
|
||||
--button-danger-color: #d43434;
|
||||
--button-focus-border-color: rgba(88, 193, 66, 0.8);
|
||||
|
||||
/* form colors */
|
||||
--form-element-border-color: rgba(241, 246, 243, 0.15);
|
||||
--form-element-bg-color: var(--main-bg-color);
|
||||
--form-element-text-color: var(--main-text-color);
|
||||
--form-element-primary-text-color: var(--main-text-color);
|
||||
--form-element-primary-color: var(--accent-color);
|
||||
--form-element-secondary-color: rgba(255, 255, 255, 0.2);
|
||||
--form-element-error-color: var(--error-color);
|
||||
}
|
||||
|
@ -26,6 +26,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { quote as shellQuote } from "shell-quote";
|
||||
|
||||
import { OverlayScrollbars } from "overlayscrollbars";
|
||||
|
||||
import "./directorypreview.less";
|
||||
|
||||
interface DirectoryTableProps {
|
||||
|
@ -1,9 +1,10 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { TypeAheadModal } from "@/app/modals/typeaheadmodal";
|
||||
import { ContextMenuModel } from "@/app/store/contextmenu";
|
||||
import { Markdown } from "@/element/markdown";
|
||||
import { createBlock, globalStore, useBlockAtom } from "@/store/global";
|
||||
import { atoms, createBlock, globalStore, useBlockAtom } from "@/store/global";
|
||||
import * as services from "@/store/services";
|
||||
import * as WOS from "@/store/wos";
|
||||
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||
@ -13,7 +14,7 @@ import * as util from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
import * as jotai from "jotai";
|
||||
import { loadable } from "jotai/utils";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { CenteredDiv } from "../../element/quickelems";
|
||||
import { CodeEditor } from "../codeeditor/codeeditor";
|
||||
import { CSVView } from "./csvview";
|
||||
@ -586,6 +587,7 @@ function PreviewView({ blockId, model }: { blockId: string; model: PreviewModel
|
||||
const fileInfo = jotai.useAtomValue(statFileAtom);
|
||||
const ceReadOnly = jotai.useAtomValue(ceReadOnlyAtom);
|
||||
const conn = jotai.useAtomValue(model.connection);
|
||||
const typeAhead = jotai.useAtomValue(atoms.typeAheadModalAtom);
|
||||
let blockIcon = iconForFile(mimeType, fileName);
|
||||
|
||||
// ensure consistent hook calls
|
||||
@ -642,6 +644,34 @@ function PreviewView({ blockId, model }: { blockId: string; model: PreviewModel
|
||||
return view;
|
||||
})();
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(waveEvent: WaveKeyboardEvent): boolean => {
|
||||
if (keyutil.checkKeyPressed(waveEvent, "Cmd:o")) {
|
||||
globalStore.set(atoms.typeAheadModalAtom, {
|
||||
...(typeAhead as TypeAheadModalType),
|
||||
[blockId]: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (keyutil.checkKeyPressed(waveEvent, "Cmd:d")) {
|
||||
globalStore.set(atoms.typeAheadModalAtom, {
|
||||
...(typeAhead as TypeAheadModalType),
|
||||
[blockId]: false,
|
||||
});
|
||||
model.giveFocus();
|
||||
return;
|
||||
}
|
||||
},
|
||||
[typeAhead, model, blockId]
|
||||
);
|
||||
|
||||
const handleFileSuggestionSelect = (value) => {
|
||||
globalStore.set(atoms.typeAheadModalAtom, {
|
||||
...(typeAhead as TypeAheadModalType),
|
||||
[blockId]: false,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const blockIconOverrideAtom = useBlockAtom<string>(blockId, "blockicon:override", () => {
|
||||
return jotai.atom<string>(null);
|
||||
@ -650,11 +680,24 @@ function PreviewView({ blockId, model }: { blockId: string; model: PreviewModel
|
||||
}, [blockId, blockIcon]);
|
||||
|
||||
return (
|
||||
<div className="full-preview scrollbar-hide-until-hover">
|
||||
<>
|
||||
{typeAhead[blockId] && (
|
||||
<TypeAheadModal
|
||||
label="Open file:"
|
||||
anchor={contentRef}
|
||||
onKeyDown={(e) => keyutil.keydownWrapper(handleKeyDown)(e)}
|
||||
onSelect={handleFileSuggestionSelect}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="full-preview scrollbar-hide-until-hover"
|
||||
onKeyDown={(e) => keyutil.keydownWrapper(handleKeyDown)(e)}
|
||||
>
|
||||
<div ref={contentRef} className="full-preview-content">
|
||||
{specializedView}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
3
frontend/types/custom.d.ts
vendored
3
frontend/types/custom.d.ts
vendored
@ -19,6 +19,7 @@ declare global {
|
||||
controlShiftDelayAtom: jotai.PrimitiveAtom<boolean>;
|
||||
reducedMotionPreferenceAtom: jotai.Atom<boolean>;
|
||||
updaterStatusAtom: jotai.PrimitiveAtom<UpdaterStatus>;
|
||||
typeAheadModalAtom: jotai.Primitive<TypeAheadModalType>;
|
||||
};
|
||||
|
||||
type WritableWaveObjectAtom<T extends WaveObj> = jotai.WritableAtom<T, [value: T], void>;
|
||||
@ -211,6 +212,8 @@ declare global {
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
type TypeAheadModalType = { [key: string]: boolean };
|
||||
}
|
||||
|
||||
export {};
|
||||
|
Loading…
Reference in New Issue
Block a user