mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-21 16:38:23 +01:00
suggestions UI (#286)
This commit is contained in:
parent
bfab5e4223
commit
c440fb774e
@ -46,7 +46,13 @@ function makeViewModel(blockId: string, blockView: string): ViewModel {
|
||||
return makeDefaultViewModel(blockId, blockView);
|
||||
}
|
||||
|
||||
function getViewElem(blockId: string, blockView: string, viewModel: ViewModel): JSX.Element {
|
||||
function getViewElem(
|
||||
blockId: string,
|
||||
blockRef: React.RefObject<HTMLDivElement>,
|
||||
contentRef: React.RefObject<HTMLDivElement>,
|
||||
blockView: string,
|
||||
viewModel: ViewModel
|
||||
): JSX.Element {
|
||||
if (util.isBlank(blockView)) {
|
||||
return <CenteredDiv>No View</CenteredDiv>;
|
||||
}
|
||||
@ -54,7 +60,15 @@ function getViewElem(blockId: string, blockView: string, viewModel: ViewModel):
|
||||
return <TerminalView key={blockId} blockId={blockId} model={viewModel as TermViewModel} />;
|
||||
}
|
||||
if (blockView === "preview") {
|
||||
return <PreviewView key={blockId} blockId={blockId} model={viewModel as PreviewModel} />;
|
||||
return (
|
||||
<PreviewView
|
||||
key={blockId}
|
||||
blockId={blockId}
|
||||
blockRef={blockRef}
|
||||
contentRef={contentRef}
|
||||
model={viewModel as PreviewModel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (blockView === "plot") {
|
||||
return <PlotView key={blockId} />;
|
||||
@ -179,7 +193,7 @@ const BlockFull = React.memo(({ nodeModel, viewModel }: FullBlockProps) => {
|
||||
}, [innerRect, disablePointerEvents, blockContentOffset]);
|
||||
|
||||
const viewElem = React.useMemo(
|
||||
() => getViewElem(nodeModel.blockId, blockData?.meta?.view, viewModel),
|
||||
() => getViewElem(nodeModel.blockId, blockRef, contentRef, blockData?.meta?.view, viewModel),
|
||||
[nodeModel.blockId, blockData?.meta?.view, viewModel]
|
||||
);
|
||||
|
||||
|
@ -128,6 +128,7 @@ const BlockFrame_Header = ({
|
||||
nodeModel,
|
||||
viewModel,
|
||||
preview,
|
||||
connBtnRef,
|
||||
changeConnModalAtom,
|
||||
}: BlockFrameProps & { changeConnModalAtom: jotai.PrimitiveAtom<boolean> }) => {
|
||||
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", nodeModel.blockId));
|
||||
@ -176,8 +177,8 @@ const BlockFrame_Header = ({
|
||||
if (manageConnection) {
|
||||
const connButtonElem = (
|
||||
<ConnectionButton
|
||||
ref={connBtnRef}
|
||||
key={nodeModel.blockId}
|
||||
blockId={nodeModel.blockId}
|
||||
connection={blockData?.meta?.connection}
|
||||
changeConnModalAtom={changeConnModalAtom}
|
||||
/>
|
||||
@ -206,7 +207,11 @@ const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; previe
|
||||
} else if (elem.elemtype == "input") {
|
||||
return <Input decl={elem} className={clsx("block-frame-input", elem.className)} preview={preview} />;
|
||||
} else if (elem.elemtype == "text") {
|
||||
return <div className="block-frame-text">{elem.text}</div>;
|
||||
return (
|
||||
<div ref={preview ? null : elem.ref} className="block-frame-text">
|
||||
{elem.text}
|
||||
</div>
|
||||
);
|
||||
} else if (elem.elemtype == "textbutton") {
|
||||
return (
|
||||
<Button className={elem.className} onClick={(e) => elem.onClick(e)}>
|
||||
@ -278,6 +283,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
|
||||
const changeConnModalAtom = useBlockAtom(nodeModel.blockId, "changeConn", () => {
|
||||
return jotai.atom(false);
|
||||
}) as jotai.PrimitiveAtom<boolean>;
|
||||
const connBtnRef = React.useRef<HTMLDivElement>();
|
||||
|
||||
const viewIconElem = getViewIconElem(viewIconUnion, blockData);
|
||||
|
||||
@ -315,18 +321,19 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
|
||||
onKeyDown={keydownWrapper(handleKeyDown)}
|
||||
>
|
||||
<BlockMask nodeModel={nodeModel} />
|
||||
<div className="block-frame-default-inner" style={innerStyle}>
|
||||
<BlockFrame_Header {...props} connBtnRef={connBtnRef} changeConnModalAtom={changeConnModalAtom} />
|
||||
{preview ? previewElem : children}
|
||||
</div>
|
||||
{preview ? null : (
|
||||
<ChangeConnectionBlockModal
|
||||
blockId={nodeModel.blockId}
|
||||
viewModel={viewModel}
|
||||
blockRef={blockModel?.blockRef}
|
||||
changeConnModalAtom={changeConnModalAtom}
|
||||
connBtnRef={connBtnRef}
|
||||
/>
|
||||
)}
|
||||
<div className="block-frame-default-inner" style={innerStyle}>
|
||||
<BlockFrame_Header {...props} changeConnModalAtom={changeConnModalAtom} />
|
||||
{preview ? previewElem : children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -336,11 +343,13 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
blockId,
|
||||
viewModel,
|
||||
blockRef,
|
||||
connBtnRef,
|
||||
changeConnModalAtom,
|
||||
}: {
|
||||
blockId: string;
|
||||
viewModel: ViewModel;
|
||||
blockRef: React.RefObject<HTMLDivElement>;
|
||||
connBtnRef: React.RefObject<HTMLDivElement>;
|
||||
changeConnModalAtom: jotai.PrimitiveAtom<boolean>;
|
||||
}) => {
|
||||
const [connSelected, setConnSelected] = React.useState("");
|
||||
@ -377,15 +386,18 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
}
|
||||
return (
|
||||
<TypeAheadModal
|
||||
anchor={blockRef}
|
||||
suggestions={[]}
|
||||
blockRef={blockRef}
|
||||
anchorRef={connBtnRef}
|
||||
// suggestions={[]}
|
||||
onSelect={(selected: string) => {
|
||||
changeConnection(selected);
|
||||
globalStore.set(changeConnModalAtom, false);
|
||||
}}
|
||||
onKeyDown={(e) => keyutil.keydownWrapper(handleTypeAheadKeyDown)(e)}
|
||||
onChange={(current: string) => setConnSelected(current)}
|
||||
value={connSelected}
|
||||
label="Switch Connection"
|
||||
label="Switch connection"
|
||||
onClickBackdrop={() => globalStore.set(changeConnModalAtom, false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -20,4 +20,5 @@ export interface BlockFrameProps {
|
||||
preview: boolean;
|
||||
numBlocksInTab?: number;
|
||||
children?: React.ReactNode;
|
||||
connBtnRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
@ -145,18 +145,15 @@ export const IconButton = React.memo(({ decl, className }: { decl: HeaderIconBut
|
||||
);
|
||||
});
|
||||
|
||||
export const ConnectionButton = React.memo(
|
||||
({
|
||||
blockId,
|
||||
connection,
|
||||
changeConnModalAtom,
|
||||
}: {
|
||||
blockId: string;
|
||||
interface ConnectionButtonProps {
|
||||
connection: string;
|
||||
changeConnModalAtom: jotai.PrimitiveAtom<boolean>;
|
||||
}) => {
|
||||
}
|
||||
|
||||
export const ConnectionButton = React.memo(
|
||||
React.forwardRef<HTMLDivElement, ConnectionButtonProps>(
|
||||
({ connection, changeConnModalAtom }: ConnectionButtonProps, ref) => {
|
||||
const [connModalOpen, setConnModalOpen] = jotai.useAtom(changeConnModalAtom);
|
||||
const buttonRef = React.useRef<HTMLDivElement>(null);
|
||||
const isLocal = util.isBlank(connection) || connection == "local";
|
||||
const connStatusAtom = getConnStatusAtom(connection);
|
||||
const connStatus = jotai.useAtomValue(connStatusAtom);
|
||||
@ -191,7 +188,7 @@ export const ConnectionButton = React.memo(
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={buttonRef} className={clsx("connection-button")} onClick={clickHandler} title={titleText}>
|
||||
<div ref={ref} className={clsx("connection-button")} onClick={clickHandler} title={titleText}>
|
||||
<span className="fa-stack connection-icon-box">
|
||||
{connIconElem}
|
||||
<i
|
||||
@ -208,6 +205,7 @@ export const ConnectionButton = React.memo(
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const Input = React.memo(
|
||||
|
@ -8,6 +8,7 @@
|
||||
position: relative;
|
||||
min-height: 32px;
|
||||
min-width: 100px;
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
border: 2px solid var(--form-element-border-color);
|
||||
background: var(--form-element-bg-color);
|
||||
|
@ -1,57 +1,39 @@
|
||||
// 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);
|
||||
@import "../mixins.less";
|
||||
|
||||
.type-ahead-modal-backdrop {
|
||||
.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);
|
||||
}
|
||||
background-color: transparent;
|
||||
z-index: var(--zindex-typeahead-modal-backdrop);
|
||||
}
|
||||
|
||||
.type-ahead-modal {
|
||||
position: relative;
|
||||
z-index: var(--zindex-modal);
|
||||
position: fixed;
|
||||
z-index: var(--zindex-typeahead-modal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 0.5px solid var(--modal-border-color);
|
||||
border: 1px solid var(--modal-border-color);
|
||||
background: var(--modal-bg-color);
|
||||
box-shadow: 0px 8px 32px 0px rgba(0, 0, 0, 0.25);
|
||||
height: auto;
|
||||
|
||||
.modal-close-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
padding: 8px 12px;
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
box-shadow: 0px 13px 16px 0px rgba(0, 0, 0, 0.4);
|
||||
|
||||
.content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 100px;
|
||||
padding: 6px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
&.has-suggestions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
opacity: 0.5;
|
||||
@ -59,25 +41,67 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.input {
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
height: 24px;
|
||||
border-radius: 0;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.input-decoration.end-position {
|
||||
margin: 6px;
|
||||
|
||||
i {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions-wrapper {
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.suggestion {
|
||||
.suggestion-header {
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 12px;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.11px;
|
||||
padding: 4px 0px 0px 4px;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
padding: 7px 10px;
|
||||
display: flex;
|
||||
padding: 8px 6px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--highlight-bg-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
.ellipsis();
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,101 +1,80 @@
|
||||
import { Input } from "@/app/element/input";
|
||||
import { InputDecoration } from "@/app/element/inputdecoration";
|
||||
import { useDimensions } from "@/app/hook/useDimensions";
|
||||
import { makeIconClass } from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
import React, { forwardRef, 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 ConnStatus = "connected" | "connecting" | "disconnected" | "error";
|
||||
|
||||
type SuggestionType = {
|
||||
interface BaseItem {
|
||||
label: string;
|
||||
icon?: string | React.ReactNode;
|
||||
}
|
||||
|
||||
interface FileItem extends BaseItem {
|
||||
value: string;
|
||||
icon?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ConnectionItem extends BaseItem {
|
||||
status: ConnStatus;
|
||||
iconColor: string;
|
||||
}
|
||||
|
||||
interface ConnectionScope {
|
||||
headerText?: string;
|
||||
items: ConnectionItem[];
|
||||
}
|
||||
|
||||
type SuggestionsType = FileItem | ConnectionItem | ConnectionScope;
|
||||
|
||||
interface SuggestionsProps {
|
||||
suggestions?: SuggestionType[];
|
||||
suggestions?: SuggestionsType[];
|
||||
onSelect?: (_: string) => void;
|
||||
}
|
||||
|
||||
const Suggestions = forwardRef<HTMLDivElement, SuggestionsProps>(({ suggestions, onSelect }: SuggestionsProps, ref) => {
|
||||
return (
|
||||
<div ref={ref} 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}
|
||||
const renderIcon = (icon: string | React.ReactNode) => {
|
||||
if (typeof icon === "string") {
|
||||
return <i className={makeIconClass(icon, false)}></i>;
|
||||
}
|
||||
return icon;
|
||||
};
|
||||
|
||||
const renderItem = (item: BaseItem | ConnectionItem, index: number) => (
|
||||
<div key={index} onClick={() => onSelect(item.label)} className="suggestion-item">
|
||||
<div className="name">
|
||||
{item.icon && renderIcon(item.icon)}
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
{"status" in item && item.status == "connected" && <i className={makeIconClass("fa-check", false)}></i>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="suggestions">
|
||||
{suggestions.map((item, index) => {
|
||||
if ("headerText" in item) {
|
||||
return (
|
||||
<div key={index}>
|
||||
{item.headerText && <div className="suggestion-header">{item.headerText}</div>}
|
||||
{item.items.map((subItem, subIndex) => renderItem(subItem, subIndex))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return renderItem(item as BaseItem, index);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface TypeAheadModalProps {
|
||||
anchor: React.MutableRefObject<HTMLDivElement>;
|
||||
suggestions?: SuggestionType[];
|
||||
anchorRef: React.RefObject<HTMLDivElement>;
|
||||
blockRef?: React.RefObject<HTMLDivElement>;
|
||||
suggestions?: SuggestionsType[];
|
||||
label?: string;
|
||||
className?: string;
|
||||
value?: string;
|
||||
@ -107,29 +86,30 @@ interface TypeAheadModalProps {
|
||||
|
||||
const TypeAheadModal = ({
|
||||
className,
|
||||
suggestions = dummy,
|
||||
suggestions,
|
||||
label,
|
||||
anchor,
|
||||
anchorRef,
|
||||
blockRef,
|
||||
value,
|
||||
onChange,
|
||||
onSelect,
|
||||
onClickBackdrop,
|
||||
onKeyDown,
|
||||
onClickBackdrop,
|
||||
}: TypeAheadModalProps) => {
|
||||
const { width, height } = useDimensions(anchor);
|
||||
const { width, height } = useDimensions(blockRef);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
const suggestionsWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
const [suggestionsHeight, setSuggestionsHeight] = useState<number | undefined>(undefined);
|
||||
const [modalHeight, setModalHeight] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (modalRef.current && inputRef.current && suggestionsWrapperRef.current) {
|
||||
if (modalRef.current && inputRef.current && suggestionsRef.current) {
|
||||
const modalPadding = 32;
|
||||
const inputHeight = inputRef.current.getBoundingClientRect().height;
|
||||
let suggestionsTotalHeight = 0;
|
||||
|
||||
const suggestionItems = suggestionsWrapperRef.current.children;
|
||||
const suggestionItems = suggestionsRef.current.children;
|
||||
for (let i = 0; i < suggestionItems.length; i++) {
|
||||
suggestionsTotalHeight += suggestionItems[i].getBoundingClientRect().height;
|
||||
}
|
||||
@ -143,7 +123,7 @@ const TypeAheadModal = ({
|
||||
const padding = 16 * 2;
|
||||
setSuggestionsHeight(computedHeight - inputHeight - padding);
|
||||
}
|
||||
}, [height, suggestions.length]);
|
||||
}, [height, suggestions]);
|
||||
|
||||
const renderBackdrop = (onClick) => <div className="type-ahead-modal-backdrop" onClick={onClick}></div>;
|
||||
|
||||
@ -159,10 +139,18 @@ const TypeAheadModal = ({
|
||||
onSelect && onSelect(value);
|
||||
};
|
||||
|
||||
let modalWidth = width * 0.6;
|
||||
let modalWidth = 300;
|
||||
if (modalWidth < 300) {
|
||||
modalWidth = Math.min(300, width * 0.95);
|
||||
}
|
||||
|
||||
const anchorRect = anchorRef.current.getBoundingClientRect();
|
||||
const blockRect = blockRef.current.getBoundingClientRect();
|
||||
|
||||
// Calculate positions relative to the wrapper
|
||||
const topPosition = 30; // Adjusting the modal to be just below the anchor
|
||||
const leftPosition = anchorRect.left - blockRect.left; // Relative left position to the wrapper div
|
||||
|
||||
const renderModal = () => (
|
||||
<div className="type-ahead-modal-wrapper" onKeyDown={handleKeyDown}>
|
||||
{renderBackdrop(onClickBackdrop)}
|
||||
@ -170,20 +158,23 @@ const TypeAheadModal = ({
|
||||
ref={modalRef}
|
||||
className={clsx("type-ahead-modal", className)}
|
||||
style={{
|
||||
top: topPosition,
|
||||
left: leftPosition,
|
||||
width: modalWidth,
|
||||
maxHeight: modalHeight,
|
||||
}}
|
||||
>
|
||||
<div className="content-wrapper">
|
||||
<div className={clsx("content-wrapper", { "has-suggestions": suggestions?.length })}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
onChange={handleChange}
|
||||
value={value}
|
||||
autoFocus
|
||||
placeholder={label}
|
||||
decoration={{
|
||||
startDecoration: (
|
||||
<InputDecoration position="start">
|
||||
<div className="label">{label}</div>
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
<i className="fa-regular fa-magnifying-glass"></i>
|
||||
</InputDecoration>
|
||||
),
|
||||
}}
|
||||
@ -196,19 +187,21 @@ const TypeAheadModal = ({
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<Suggestions ref={suggestionsWrapperRef} suggestions={suggestions} onSelect={handleSelect} />
|
||||
{suggestions && (
|
||||
<Suggestions ref={suggestionsRef} suggestions={suggestions} onSelect={handleSelect} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (anchor.current == null) {
|
||||
if (blockRef && blockRef.current == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(renderModal(), anchor.current);
|
||||
return ReactDOM.createPortal(renderModal(), blockRef.current);
|
||||
};
|
||||
|
||||
export { TypeAheadModal };
|
||||
export type { SuggestionType };
|
||||
export type { SuggestionsType };
|
||||
|
@ -42,6 +42,8 @@
|
||||
--zindex-modal: 2;
|
||||
--zindex-modal-wrapper: 500;
|
||||
--zindex-modal-backdrop: 1;
|
||||
--zindex-typeahead-modal: 100;
|
||||
--zindex-typeahead-modal-backdrop: 90;
|
||||
--zindex-elem-modal: 100;
|
||||
--zindex-window-drag: 100;
|
||||
--zindex-tab-name: 3;
|
||||
@ -66,7 +68,8 @@
|
||||
/* modal colors */
|
||||
--modal-bg-color: #232323;
|
||||
--modal-header-bottom-border-color: rgba(241, 246, 243, 0.15);
|
||||
--modal-border-color: rgba(255, 255, 255, 0.12) /* toggle colors */ --toggle-bg-color: var(--border-color);
|
||||
--modal-border-color: rgba(255, 255, 255, 0.12); /* toggle colors */
|
||||
--toggle-bg-color: var(--border-color);
|
||||
|
||||
--toggle-thumb-color: var(--main-text-color);
|
||||
--toggle-checked-bg-color: var(--accent-color);
|
||||
|
@ -14,7 +14,7 @@ import * as util from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
import * as jotai from "jotai";
|
||||
import { loadable } from "jotai/utils";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { createRef, useCallback, useEffect, useState } from "react";
|
||||
import { CenteredDiv } from "../../element/quickelems";
|
||||
import { CodeEditor } from "../codeeditor/codeeditor";
|
||||
import { CSVView } from "./csvview";
|
||||
@ -46,6 +46,7 @@ export class PreviewModel implements ViewModel {
|
||||
endIconButtons: jotai.Atom<HeaderIconButton[]>;
|
||||
ceReadOnly: jotai.PrimitiveAtom<boolean>;
|
||||
isCeView: jotai.PrimitiveAtom<boolean>;
|
||||
previewTextRef: React.RefObject<HTMLDivElement>;
|
||||
|
||||
fileName: jotai.Atom<string>;
|
||||
connection: jotai.Atom<string>;
|
||||
@ -70,6 +71,7 @@ export class PreviewModel implements ViewModel {
|
||||
this.blockId = blockId;
|
||||
this.showHiddenFiles = jotai.atom(true);
|
||||
this.refreshVersion = jotai.atom(0);
|
||||
this.previewTextRef = createRef();
|
||||
this.ceReadOnly = jotai.atom(true);
|
||||
this.isCeView = jotai.atom(false);
|
||||
this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
|
||||
@ -162,6 +164,7 @@ export class PreviewModel implements ViewModel {
|
||||
{
|
||||
elemtype: "text",
|
||||
text: get(this.fileName),
|
||||
ref: this.previewTextRef,
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -241,12 +244,70 @@ export class PreviewModel implements ViewModel {
|
||||
this.goParentDirectory = this.goParentDirectory.bind(this);
|
||||
}
|
||||
|
||||
goHistory(newPath: string) {
|
||||
const blockMeta = globalStore.get(this.blockAtom)?.meta;
|
||||
async resolvePath(filePath, basePath) {
|
||||
// Handle paths starting with "~" to refer to the home directory
|
||||
if (filePath.startsWith("~")) {
|
||||
try {
|
||||
const conn = globalStore.get(this.connection);
|
||||
const sf = await services.FileService.StatFile(conn, "~");
|
||||
basePath = sf.path; // Update basePath to the fetched home directory path
|
||||
filePath = basePath + filePath.slice(1); // Replace "~" with the fetched home directory path
|
||||
} catch (error) {
|
||||
console.error("Error fetching home directory:", error);
|
||||
return basePath;
|
||||
}
|
||||
}
|
||||
// If filePath is an absolute path, return it directly
|
||||
if (filePath.startsWith("/")) {
|
||||
return filePath;
|
||||
}
|
||||
const stack = basePath.split("/");
|
||||
// Ensure no empty segments from trailing slashes
|
||||
if (stack[stack.length - 1] === "") {
|
||||
stack.pop();
|
||||
}
|
||||
// Process the filePath parts
|
||||
filePath.split("/").forEach((part) => {
|
||||
if (part === "..") {
|
||||
// Go up one level, avoid going above root level
|
||||
if (stack.length > 1) {
|
||||
stack.pop();
|
||||
}
|
||||
} else if (part === "." || part === "") {
|
||||
// Ignore current directory marker and empty parts
|
||||
} else {
|
||||
// Normal path part, add to the stack
|
||||
stack.push(part);
|
||||
}
|
||||
});
|
||||
return stack.join("/");
|
||||
}
|
||||
|
||||
async isValidPath(path) {
|
||||
try {
|
||||
const conn = globalStore.get(this.connection);
|
||||
const sf = await services.FileService.StatFile(conn, path);
|
||||
const isValid = !sf.notfound;
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
console.error("Error checking path validity:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async goHistory(newPath, isValidated = false) {
|
||||
const fileName = globalStore.get(this.fileName);
|
||||
if (fileName == null) {
|
||||
return;
|
||||
}
|
||||
if (!isValidated) {
|
||||
newPath = await this.resolvePath(newPath, fileName);
|
||||
const isValid = await this.isValidPath(newPath);
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const blockMeta = globalStore.get(this.blockAtom)?.meta;
|
||||
const updateMeta = historyutil.goHistory("file", fileName, newPath, blockMeta);
|
||||
if (updateMeta == null) {
|
||||
return;
|
||||
@ -572,8 +633,17 @@ function iconForFile(mimeType: string, fileName: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function PreviewView({ blockId, model }: { blockId: string; model: PreviewModel }) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
function PreviewView({
|
||||
blockId,
|
||||
blockRef,
|
||||
contentRef,
|
||||
model,
|
||||
}: {
|
||||
blockId: string;
|
||||
blockRef: React.RefObject<HTMLDivElement>;
|
||||
contentRef: React.RefObject<HTMLDivElement>;
|
||||
model: PreviewModel;
|
||||
}) {
|
||||
const fileNameAtom = model.fileName;
|
||||
const statFileAtom = model.statFile;
|
||||
const fileMimeTypeAtom = model.fileMimeType;
|
||||
@ -590,6 +660,10 @@ function PreviewView({ blockId, model }: { blockId: string; model: PreviewModel
|
||||
const typeAhead = jotai.useAtomValue(atoms.typeAheadModalAtom);
|
||||
let blockIcon = iconForFile(mimeType, fileName);
|
||||
|
||||
const [filePath, setFilePath] = useState("");
|
||||
const [openFileError, setOpenFileError] = useState("");
|
||||
const [openFileModal, setOpenFileModal] = useState(false);
|
||||
|
||||
// ensure consistent hook calls
|
||||
const specializedView = (() => {
|
||||
let view: React.ReactNode = null;
|
||||
@ -646,23 +720,47 @@ function PreviewView({ blockId, model }: { blockId: string; model: PreviewModel
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(waveEvent: WaveKeyboardEvent): boolean => {
|
||||
const updateModalAndError = (isOpen, errorMsg = "") => {
|
||||
setOpenFileModal(isOpen);
|
||||
setOpenFileError(errorMsg);
|
||||
};
|
||||
|
||||
const handleEnterPress = async () => {
|
||||
const newPath = await model.resolvePath(filePath, fileName);
|
||||
const isValidPath = await model.isValidPath(newPath);
|
||||
if (isValidPath) {
|
||||
updateModalAndError(false);
|
||||
await model.goHistory(newPath, true);
|
||||
} else {
|
||||
updateModalAndError(true, "The path you entered does not exist.");
|
||||
}
|
||||
model.giveFocus();
|
||||
return isValidPath;
|
||||
};
|
||||
|
||||
const handleCommandOperations = async () => {
|
||||
if (keyutil.checkKeyPressed(waveEvent, "Cmd:o")) {
|
||||
globalStore.set(atoms.typeAheadModalAtom, {
|
||||
...(typeAhead as TypeAheadModalType),
|
||||
[blockId]: true,
|
||||
});
|
||||
updateModalAndError(true);
|
||||
return true;
|
||||
}
|
||||
if (keyutil.checkKeyPressed(waveEvent, "Cmd:d") || keyutil.checkKeyPressed(waveEvent, "Enter")) {
|
||||
globalStore.set(atoms.typeAheadModalAtom, {
|
||||
...(typeAhead as TypeAheadModalType),
|
||||
[blockId]: false,
|
||||
});
|
||||
model.giveFocus();
|
||||
return;
|
||||
if (keyutil.checkKeyPressed(waveEvent, "Cmd:d")) {
|
||||
updateModalAndError(false);
|
||||
return false;
|
||||
}
|
||||
if (keyutil.checkKeyPressed(waveEvent, "Enter")) {
|
||||
return handleEnterPress();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
handleCommandOperations().catch((error) => {
|
||||
console.error("Error handling key down:", error);
|
||||
updateModalAndError(true, "An error occurred during operation.");
|
||||
return false;
|
||||
});
|
||||
return false;
|
||||
},
|
||||
[typeAhead, model, blockId]
|
||||
[typeAhead, model, blockId, filePath, fileName]
|
||||
);
|
||||
|
||||
const handleFileSuggestionSelect = (value) => {
|
||||
@ -672,6 +770,10 @@ function PreviewView({ blockId, model }: { blockId: string; model: PreviewModel
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileSuggestionChange = (value) => {
|
||||
setFilePath(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const blockIconOverrideAtom = useBlockAtom<string>(blockId, "blockicon:override", () => {
|
||||
return jotai.atom<string>(null);
|
||||
@ -681,12 +783,16 @@ function PreviewView({ blockId, model }: { blockId: string; model: PreviewModel
|
||||
|
||||
return (
|
||||
<>
|
||||
{typeAhead[blockId] && (
|
||||
{openFileModal && (
|
||||
<TypeAheadModal
|
||||
label="Open file:"
|
||||
anchor={contentRef}
|
||||
label="Open file"
|
||||
suggestions={[]}
|
||||
blockRef={blockRef}
|
||||
anchorRef={model.previewTextRef}
|
||||
onKeyDown={(e) => keyutil.keydownWrapper(handleKeyDown)(e)}
|
||||
onSelect={handleFileSuggestionSelect}
|
||||
onChange={handleFileSuggestionChange}
|
||||
onClickBackdrop={() => setOpenFileModal(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
|
1
frontend/types/custom.d.ts
vendored
1
frontend/types/custom.d.ts
vendored
@ -161,6 +161,7 @@ declare global {
|
||||
type HeaderText = {
|
||||
elemtype: "text";
|
||||
text: string;
|
||||
ref?: React.MutableRefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
type HeaderInput = {
|
||||
|
Loading…
Reference in New Issue
Block a user