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 {
|
.block-content {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
width: 100%;
|
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);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
input {
|
input {
|
||||||
@ -255,14 +257,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.is-layoutmode .block-mask-inner {
|
&.is-layoutmode .block-mask-inner {
|
||||||
|
margin-top: 35px; // TODO fix this magic
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
height: 100%;
|
height: calc(100% - 35px);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
.bignum {
|
.bignum {
|
||||||
|
margin-top: -15%;
|
||||||
font-size: 60px;
|
font-size: 60px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
opacity: 0.7;
|
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();
|
const currentDate = new Date();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal className="about-modal" title="About" onClose={() => modalsModel.popModal()}>
|
<Modal className="about-modal" onClose={() => modalsModel.popModal()}>
|
||||||
<div className="section-wrapper">
|
<div className="section-wrapper">
|
||||||
<div className="section logo-section">
|
<div className="section logo-section">
|
||||||
<Logo />
|
<Logo />
|
||||||
|
@ -29,42 +29,29 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 32px;
|
|
||||||
padding: 24px 16px 16px;
|
padding: 24px 16px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 0.5px solid var(--modal-border-color);
|
border: 0.5px solid var(--modal-border-color);
|
||||||
background: var(--modal-bg-color);
|
background: var(--modal-bg-color);
|
||||||
box-shadow: 0px 8px 32px 0px rgba(0, 0, 0, 0.25);
|
box-shadow: 0px 8px 32px 0px rgba(0, 0, 0, 0.25);
|
||||||
|
|
||||||
.header-content-wrapper {
|
.modal-close-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 100%;
|
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 {
|
|
||||||
position: absolute;
|
|
||||||
right: 8px;
|
|
||||||
top: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0px 20px;
|
padding: 0px 20px;
|
||||||
|
@ -7,22 +7,6 @@ import ReactDOM from "react-dom";
|
|||||||
|
|
||||||
import "./modal.less";
|
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 {
|
interface ModalContentProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
@ -56,7 +40,6 @@ const ModalFooter = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok" }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
title: string;
|
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
description?: string;
|
description?: string;
|
||||||
okLabel?: string;
|
okLabel?: string;
|
||||||
@ -71,7 +54,6 @@ interface ModalProps {
|
|||||||
const Modal = ({
|
const Modal = ({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
title,
|
|
||||||
description,
|
description,
|
||||||
cancelLabel,
|
cancelLabel,
|
||||||
okLabel,
|
okLabel,
|
||||||
@ -90,8 +72,10 @@ const Modal = ({
|
|||||||
<div className="modal-wrapper">
|
<div className="modal-wrapper">
|
||||||
{renderBackdrop(onClickBackdrop)}
|
{renderBackdrop(onClickBackdrop)}
|
||||||
<div className={clsx(`modal`, className)}>
|
<div className={clsx(`modal`, className)}>
|
||||||
<div className="header-content-wrapper">
|
<Button className="secondary ghost modal-close-btn" onClick={onClose} title="Close (ESC)">
|
||||||
<ModalHeader onClose={onClose} description={description} />
|
<i className="fa-sharp fa-solid fa-xmark"></i>
|
||||||
|
</Button>
|
||||||
|
<div className="content-wrapper">
|
||||||
<ModalContent>{children}</ModalContent>
|
<ModalContent>{children}</ModalContent>
|
||||||
</div>
|
</div>
|
||||||
{renderFooter() && (
|
{renderFooter() && (
|
||||||
@ -123,7 +107,6 @@ const FlexiModal = ({ children, className, onClickBackdrop }: FlexiModalProps) =
|
|||||||
return ReactDOM.createPortal(renderModal(), document.getElementById("main"));
|
return ReactDOM.createPortal(renderModal(), document.getElementById("main"));
|
||||||
};
|
};
|
||||||
|
|
||||||
FlexiModal.Header = ModalHeader;
|
|
||||||
FlexiModal.Content = ModalContent;
|
FlexiModal.Content = ModalContent;
|
||||||
FlexiModal.Footer = ModalFooter;
|
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]);
|
}, [countdown]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal onOk={() => handleSubmit()} onCancel={() => handleSendCancel()}>
|
||||||
title={userInputRequest.title + ` (${countdown}s)`}
|
|
||||||
onOk={() => handleSubmit()}
|
|
||||||
onCancel={() => handleSendCancel()}
|
|
||||||
>
|
|
||||||
<div className="userinput-body">
|
<div className="userinput-body">
|
||||||
|
{userInputRequest.title + ` (${countdown}s)`}
|
||||||
{queryText}
|
{queryText}
|
||||||
{inputBox}
|
{inputBox}
|
||||||
</div>
|
</div>
|
||||||
|
@ -123,6 +123,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
|||||||
// do nothing
|
// do nothing
|
||||||
}
|
}
|
||||||
const reducedMotionPreferenceAtom = jotai.atom((get) => get(settingsConfigAtom).window.reducedmotion);
|
const reducedMotionPreferenceAtom = jotai.atom((get) => get(settingsConfigAtom).window.reducedmotion);
|
||||||
|
const typeAheadModalAtom = jotai.atom({});
|
||||||
atoms = {
|
atoms = {
|
||||||
// initialized in wave.ts (will not be null inside of application)
|
// initialized in wave.ts (will not be null inside of application)
|
||||||
windowId: windowIdAtom,
|
windowId: windowIdAtom,
|
||||||
@ -138,6 +139,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
|||||||
controlShiftDelayAtom,
|
controlShiftDelayAtom,
|
||||||
updaterStatusAtom,
|
updaterStatusAtom,
|
||||||
reducedMotionPreferenceAtom,
|
reducedMotionPreferenceAtom,
|
||||||
|
typeAheadModalAtom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,4 +79,13 @@
|
|||||||
--button-secondary-color: rgba(255, 255, 255, 0.1);
|
--button-secondary-color: rgba(255, 255, 255, 0.1);
|
||||||
--button-danger-color: #d43434;
|
--button-danger-color: #d43434;
|
||||||
--button-focus-border-color: rgba(88, 193, 66, 0.8);
|
--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 { quote as shellQuote } from "shell-quote";
|
||||||
|
|
||||||
import { OverlayScrollbars } from "overlayscrollbars";
|
import { OverlayScrollbars } from "overlayscrollbars";
|
||||||
|
|
||||||
import "./directorypreview.less";
|
import "./directorypreview.less";
|
||||||
|
|
||||||
interface DirectoryTableProps {
|
interface DirectoryTableProps {
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
// Copyright 2024, Command Line Inc.
|
// Copyright 2024, Command Line Inc.
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
import { TypeAheadModal } from "@/app/modals/typeaheadmodal";
|
||||||
import { ContextMenuModel } from "@/app/store/contextmenu";
|
import { ContextMenuModel } from "@/app/store/contextmenu";
|
||||||
import { Markdown } from "@/element/markdown";
|
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 services from "@/store/services";
|
||||||
import * as WOS from "@/store/wos";
|
import * as WOS from "@/store/wos";
|
||||||
import { getWebServerEndpoint } from "@/util/endpoints";
|
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||||
@ -13,7 +14,7 @@ import * as util from "@/util/util";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import * as jotai from "jotai";
|
import * as jotai from "jotai";
|
||||||
import { loadable } from "jotai/utils";
|
import { loadable } from "jotai/utils";
|
||||||
import { useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { CenteredDiv } from "../../element/quickelems";
|
import { CenteredDiv } from "../../element/quickelems";
|
||||||
import { CodeEditor } from "../codeeditor/codeeditor";
|
import { CodeEditor } from "../codeeditor/codeeditor";
|
||||||
import { CSVView } from "./csvview";
|
import { CSVView } from "./csvview";
|
||||||
@ -586,6 +587,7 @@ function PreviewView({ blockId, model }: { blockId: string; model: PreviewModel
|
|||||||
const fileInfo = jotai.useAtomValue(statFileAtom);
|
const fileInfo = jotai.useAtomValue(statFileAtom);
|
||||||
const ceReadOnly = jotai.useAtomValue(ceReadOnlyAtom);
|
const ceReadOnly = jotai.useAtomValue(ceReadOnlyAtom);
|
||||||
const conn = jotai.useAtomValue(model.connection);
|
const conn = jotai.useAtomValue(model.connection);
|
||||||
|
const typeAhead = jotai.useAtomValue(atoms.typeAheadModalAtom);
|
||||||
let blockIcon = iconForFile(mimeType, fileName);
|
let blockIcon = iconForFile(mimeType, fileName);
|
||||||
|
|
||||||
// ensure consistent hook calls
|
// ensure consistent hook calls
|
||||||
@ -642,6 +644,34 @@ function PreviewView({ blockId, model }: { blockId: string; model: PreviewModel
|
|||||||
return view;
|
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(() => {
|
useEffect(() => {
|
||||||
const blockIconOverrideAtom = useBlockAtom<string>(blockId, "blockicon:override", () => {
|
const blockIconOverrideAtom = useBlockAtom<string>(blockId, "blockicon:override", () => {
|
||||||
return jotai.atom<string>(null);
|
return jotai.atom<string>(null);
|
||||||
@ -650,11 +680,24 @@ function PreviewView({ blockId, model }: { blockId: string; model: PreviewModel
|
|||||||
}, [blockId, blockIcon]);
|
}, [blockId, blockIcon]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="full-preview scrollbar-hide-until-hover">
|
<>
|
||||||
<div ref={contentRef} className="full-preview-content">
|
{typeAhead[blockId] && (
|
||||||
{specializedView}
|
<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>
|
</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>;
|
controlShiftDelayAtom: jotai.PrimitiveAtom<boolean>;
|
||||||
reducedMotionPreferenceAtom: jotai.Atom<boolean>;
|
reducedMotionPreferenceAtom: jotai.Atom<boolean>;
|
||||||
updaterStatusAtom: jotai.PrimitiveAtom<UpdaterStatus>;
|
updaterStatusAtom: jotai.PrimitiveAtom<UpdaterStatus>;
|
||||||
|
typeAheadModalAtom: jotai.Primitive<TypeAheadModalType>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WritableWaveObjectAtom<T extends WaveObj> = jotai.WritableAtom<T, [value: T], void>;
|
type WritableWaveObjectAtom<T extends WaveObj> = jotai.WritableAtom<T, [value: T], void>;
|
||||||
@ -211,6 +212,8 @@ declare global {
|
|||||||
left: number;
|
left: number;
|
||||||
top: number;
|
top: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TypeAheadModalType = { [key: string]: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
Loading…
Reference in New Issue
Block a user