suggestions modal (#260)

This commit is contained in:
Red J Adaya 2024-08-23 15:18:49 +08:00 committed by GitHub
parent af3bc7d249
commit c1684d28d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 766 additions and 60 deletions

View File

@ -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;

View 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;
}
}
}

View 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 };

View 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;
}

View 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 };

View 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 };

View File

@ -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 />

View File

@ -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 {
.modal-close-btn {
position: absolute;
right: 8px;
top: 8px;
padding: 8px 12px;
i {
font-size: 18px;
}
}
.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 {
position: absolute;
right: 8px;
top: 8px;
padding: 8px 16px;
i {
font-size: 18px;
}
}
}
.modal-content {
width: 100%;
padding: 0px 20px;

View File

@ -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;

View 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;
}
}
}
}
}

View 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 };

View File

@ -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>

View File

@ -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,
};
}

View File

@ -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);
}

View File

@ -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 {

View File

@ -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">
<div ref={contentRef} className="full-preview-content">
{specializedView}
<>
{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>
</div>
</>
);
}

View File

@ -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 {};