Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/app/block/block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ function getViewElem(
);
}
if (blockView === "web") {
return <WebView key={blockId} blockId={blockId} model={viewModel as WebViewModel} />;
return <WebView key={blockId} blockId={blockId} model={viewModel as WebViewModel} blockRef={blockRef} />;
}
if (blockView === "waveai") {
return <WaveAi key={blockId} blockId={blockId} model={viewModel as WaveAiModel} />;
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/block/blockframe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
"--magnified-block-blur": `${magnifiedBlockBlur}px`,
} as React.CSSProperties
}
inert={preview ? "1" : undefined}
inert={preview ? "1" : undefined} // this does exist in the DOM, just not in react
>
<BlockMask nodeModel={nodeModel} />
{preview || viewModel == null ? null : (
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/store/keymodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ function registerGlobalKeys() {
});
const allKeys = Array.from(globalKeyMap.keys());
// special case keys, handled by web view
allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft");
allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft", "Cmd:o");
getApi().registerGlobalWebviewKeys(allKeys);
}

Expand Down
317 changes: 317 additions & 0 deletions frontend/app/suggestion/suggestion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { atoms } from "@/app/store/global";
import { isBlank, makeIconClass } from "@/util/util";
import { offset, useFloating } from "@floating-ui/react";
import clsx from "clsx";
import { Atom, useAtomValue } from "jotai";
import React, { ReactNode, useEffect, useId, useRef, useState } from "react";

interface SuggestionControlProps {
anchorRef: React.RefObject<HTMLElement>;
isOpen: boolean;
onClose: () => void;
onSelect: (item: SuggestionType, queryStr: string) => void;
onTab?: (item: SuggestionType, queryStr: string) => string;
fetchSuggestions: SuggestionsFnType;
className?: string;
placeholderText?: string;
children?: React.ReactNode;
}

type BlockHeaderSuggestionControlProps = Omit<SuggestionControlProps, "anchorRef" | "isOpen"> & {
blockRef: React.RefObject<HTMLElement>;
openAtom: Atom<boolean>;
};

const SuggestionControl: React.FC<SuggestionControlProps> = ({
anchorRef,
isOpen,
onClose,
onSelect,
fetchSuggestions,
className,
children,
}) => {
if (!isOpen || !anchorRef.current || !fetchSuggestions) return null;

return <SuggestionControlInner {...{ anchorRef, onClose, onSelect, fetchSuggestions, className, children }} />;
};

function highlightPositions(target: string, positions: number[]): ReactNode[] {
if (target == null) {
return [];
}
if (positions == null) {
return [target];
}
const result: ReactNode[] = [];
let targetIndex = 0;
let posIndex = 0;

while (targetIndex < target.length) {
if (posIndex < positions.length && targetIndex === positions[posIndex]) {
result.push(
<span key={`h-${targetIndex}`} className="text-blue-500 font-bold">
{target[targetIndex]}
</span>
);
posIndex++;
} else {
result.push(target[targetIndex]);
}
targetIndex++;
}
return result;
}

function getMimeTypeIconAndColor(fullConfig: FullConfigType, mimeType: string): [string, string] {
if (mimeType == null) {
return [null, null];
}
while (mimeType.length > 0) {
const icon = fullConfig.mimetypes?.[mimeType]?.icon ?? null;
const iconColor = fullConfig.mimetypes?.[mimeType]?.color ?? null;
if (icon != null) {
return [icon, iconColor];
}
mimeType = mimeType.slice(0, -1);
}
return [null, null];
}

const SuggestionIcon: React.FC<{ suggestion: SuggestionType }> = ({ suggestion }) => {
if (suggestion.iconsrc) {
return <img src={suggestion.iconsrc} alt="favicon" className="w-4 h-4 object-contain" />;
}
if (suggestion.icon) {
const iconClass = makeIconClass(suggestion.icon, true);
const iconColor = suggestion.iconcolor;
return <i className={iconClass} style={{ color: iconColor }} />;
}
if (suggestion.type === "url") {
const iconClass = makeIconClass("globe", true);
const iconColor = suggestion.iconcolor;
return <i className={iconClass} style={{ color: iconColor }} />;
} else if (suggestion.type === "file") {
// For file suggestions, use the existing logic.
const fullConfig = useAtomValue(atoms.fullConfigAtom);
let icon: string = null;
let iconColor: string = null;
if (icon == null && suggestion["file:mimetype"] != null) {
[icon, iconColor] = getMimeTypeIconAndColor(fullConfig, suggestion["file:mimetype"]);
}
const iconClass = makeIconClass(icon, true, { defaultIcon: "file" });
return <i className={iconClass} style={{ color: iconColor }} />;
}
const iconClass = makeIconClass("file", true);
return <i className={iconClass} />;
};

const SuggestionContent: React.FC<{
suggestion: SuggestionType;
}> = ({ suggestion }) => {
if (!isBlank(suggestion.subtext)) {
return (
<div className="flex flex-col">
{/* Title on the first line, with highlighting */}
<div className="truncate text-white">{highlightPositions(suggestion.display, suggestion.matchpos)}</div>
{/* Subtext on the second line in a smaller, grey style */}
<div className="truncate text-sm text-secondary">
{highlightPositions(suggestion.subtext, suggestion.submatchpos)}
</div>
</div>
);
}
return <span className="truncate">{highlightPositions(suggestion.display, suggestion.matchpos)}</span>;
};

const BlockHeaderSuggestionControl: React.FC<BlockHeaderSuggestionControlProps> = (props) => {
const [headerElem, setHeaderElem] = useState<HTMLElement>(null);
const isOpen = useAtomValue(props.openAtom);

useEffect(() => {
if (props.blockRef.current == null) {
setHeaderElem(null);
return;
}
const headerElem = props.blockRef.current.querySelector("[data-role='block-header']");
setHeaderElem(headerElem as HTMLElement);
}, [props.blockRef.current]);

const newClass = clsx(props.className, "rounded-t-none");
return <SuggestionControl {...props} anchorRef={{ current: headerElem }} isOpen={isOpen} className={newClass} />;
};

/**
* The empty state component that can be used as a child of SuggestionControl.
* If no children are provided to SuggestionControl, this default empty state will be used.
*/
const SuggestionControlNoResults: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
return (
<div className="flex items-center justify-center min-h-[120px] p-4">
{children ?? <span className="text-gray-500">No Suggestions</span>}
</div>
);
};

const SuggestionControlNoData: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
return (
<div className="flex items-center justify-center min-h-[120px] p-4">
{children ?? <span className="text-gray-500">No Suggestions</span>}
</div>
);
};

interface SuggestionControlInnerProps extends Omit<SuggestionControlProps, "isOpen"> {}

const SuggestionControlInner: React.FC<SuggestionControlInnerProps> = ({
anchorRef,
onClose,
onSelect,
onTab,
fetchSuggestions,
className,
placeholderText,
children,
}) => {
const widgetId = useId();
const [query, setQuery] = useState("");
const reqNumRef = useRef(0);
let [suggestions, setSuggestions] = useState<SuggestionType[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [fetched, setFetched] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "bottom",
strategy: "absolute",
middleware: [offset(-1)],
});
const emptyStateChild = React.Children.toArray(children).find(
(child) => React.isValidElement(child) && child.type === SuggestionControlNoResults
);
const noDataChild = React.Children.toArray(children).find(
(child) => React.isValidElement(child) && child.type === SuggestionControlNoData
);

useEffect(() => {
refs.setReference(anchorRef.current);
}, [anchorRef.current]);

useEffect(() => {
reqNumRef.current++;
fetchSuggestions(query, { widgetid: widgetId, reqnum: reqNumRef.current }).then((results) => {
if (results.reqnum !== reqNumRef.current) {
return;
}
setSuggestions(results.suggestions ?? []);
setFetched(true);
});
}, [query, fetchSuggestions]);

useEffect(() => {
return () => {
reqNumRef.current++;
fetchSuggestions("", { widgetid: widgetId, reqnum: reqNumRef.current, dispose: true });
};
}, []);

useEffect(() => {
inputRef.current?.focus();
}, []);

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose, anchorRef]);

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter" && selectedIndex >= 0) {
e.preventDefault();
onSelect(suggestions[selectedIndex], query);
onClose();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
} else if (e.key === "Tab") {
e.preventDefault();
const suggestion = suggestions[selectedIndex];
if (suggestion != null) {
const tabResult = onTab?.(suggestion, query);
if (tabResult != null) {
setQuery(tabResult);
}
}
}
};
return (
<div
className={clsx(
"w-96 rounded-lg bg-modalbg shadow-lg border border-gray-700 z-[var(--zindex-typeahead-modal)] absolute",
middlewareData?.offset == null ? "opacity-0" : null,
className
)}
ref={refs.setFloating}
style={floatingStyles}
>
<div className="p-2">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleKeyDown}
className="w-full bg-gray-900 text-gray-100 px-4 py-2 rounded-md border border-gray-700 focus:outline-none focus:border-accent placeholder-secondary"
placeholder={placeholderText}
/>
</div>
{fetched &&
(suggestions.length > 0 ? (
<div ref={dropdownRef} className="max-h-96 overflow-y-auto divide-y divide-gray-700">
{suggestions.map((suggestion, index) => (
<div
key={suggestion.suggestionid}
className={clsx(
"flex items-center gap-3 px-4 py-2 cursor-pointer",
index === selectedIndex ? "bg-accentbg" : "hover:bg-hoverbg",
"text-gray-100"
)}
onClick={() => {
onSelect(suggestion, query);
onClose();
}}
>
<SuggestionIcon suggestion={suggestion} />
<SuggestionContent suggestion={suggestion} />
</div>
))}
</div>
) : (
// Render the empty state (either a provided child or the default)
<div key="empty" className="flex items-center justify-center min-h-[120px] p-4">
{query === ""
? (noDataChild ?? <SuggestionControlNoData />)
: (emptyStateChild ?? <SuggestionControlNoResults />)}
</div>
))}
</div>
);
};

export { BlockHeaderSuggestionControl, SuggestionControl, SuggestionControlNoData, SuggestionControlNoResults };
Loading
Loading