Skip to content

Commit a733812

Browse files
authored
web bookmarks (#1930)
1 parent 3e0712c commit a733812

File tree

19 files changed

+899
-311
lines changed

19 files changed

+899
-311
lines changed

frontend/app/block/block.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ function getViewElem(
8888
);
8989
}
9090
if (blockView === "web") {
91-
return <WebView key={blockId} blockId={blockId} model={viewModel as WebViewModel} />;
91+
return <WebView key={blockId} blockId={blockId} model={viewModel as WebViewModel} blockRef={blockRef} />;
9292
}
9393
if (blockView === "waveai") {
9494
return <WaveAi key={blockId} blockId={blockId} model={viewModel as WaveAiModel} />;

frontend/app/block/blockframe.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
607607
"--magnified-block-blur": `${magnifiedBlockBlur}px`,
608608
} as React.CSSProperties
609609
}
610-
inert={preview ? "1" : undefined}
610+
inert={preview ? "1" : undefined} // this does exist in the DOM, just not in react
611611
>
612612
<BlockMask nodeModel={nodeModel} />
613613
{preview || viewModel == null ? null : (

frontend/app/store/keymodel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ function registerGlobalKeys() {
397397
});
398398
const allKeys = Array.from(globalKeyMap.keys());
399399
// special case keys, handled by web view
400-
allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft");
400+
allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft", "Cmd:o");
401401
getApi().registerGlobalWebviewKeys(allKeys);
402402
}
403403

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { atoms } from "@/app/store/global";
5+
import { isBlank, makeIconClass } from "@/util/util";
6+
import { offset, useFloating } from "@floating-ui/react";
7+
import clsx from "clsx";
8+
import { Atom, useAtomValue } from "jotai";
9+
import React, { ReactNode, useEffect, useId, useRef, useState } from "react";
10+
11+
interface SuggestionControlProps {
12+
anchorRef: React.RefObject<HTMLElement>;
13+
isOpen: boolean;
14+
onClose: () => void;
15+
onSelect: (item: SuggestionType, queryStr: string) => void;
16+
onTab?: (item: SuggestionType, queryStr: string) => string;
17+
fetchSuggestions: SuggestionsFnType;
18+
className?: string;
19+
placeholderText?: string;
20+
children?: React.ReactNode;
21+
}
22+
23+
type BlockHeaderSuggestionControlProps = Omit<SuggestionControlProps, "anchorRef" | "isOpen"> & {
24+
blockRef: React.RefObject<HTMLElement>;
25+
openAtom: Atom<boolean>;
26+
};
27+
28+
const SuggestionControl: React.FC<SuggestionControlProps> = ({
29+
anchorRef,
30+
isOpen,
31+
onClose,
32+
onSelect,
33+
fetchSuggestions,
34+
className,
35+
children,
36+
}) => {
37+
if (!isOpen || !anchorRef.current || !fetchSuggestions) return null;
38+
39+
return <SuggestionControlInner {...{ anchorRef, onClose, onSelect, fetchSuggestions, className, children }} />;
40+
};
41+
42+
function highlightPositions(target: string, positions: number[]): ReactNode[] {
43+
if (target == null) {
44+
return [];
45+
}
46+
if (positions == null) {
47+
return [target];
48+
}
49+
const result: ReactNode[] = [];
50+
let targetIndex = 0;
51+
let posIndex = 0;
52+
53+
while (targetIndex < target.length) {
54+
if (posIndex < positions.length && targetIndex === positions[posIndex]) {
55+
result.push(
56+
<span key={`h-${targetIndex}`} className="text-blue-500 font-bold">
57+
{target[targetIndex]}
58+
</span>
59+
);
60+
posIndex++;
61+
} else {
62+
result.push(target[targetIndex]);
63+
}
64+
targetIndex++;
65+
}
66+
return result;
67+
}
68+
69+
function getMimeTypeIconAndColor(fullConfig: FullConfigType, mimeType: string): [string, string] {
70+
if (mimeType == null) {
71+
return [null, null];
72+
}
73+
while (mimeType.length > 0) {
74+
const icon = fullConfig.mimetypes?.[mimeType]?.icon ?? null;
75+
const iconColor = fullConfig.mimetypes?.[mimeType]?.color ?? null;
76+
if (icon != null) {
77+
return [icon, iconColor];
78+
}
79+
mimeType = mimeType.slice(0, -1);
80+
}
81+
return [null, null];
82+
}
83+
84+
const SuggestionIcon: React.FC<{ suggestion: SuggestionType }> = ({ suggestion }) => {
85+
if (suggestion.iconsrc) {
86+
return <img src={suggestion.iconsrc} alt="favicon" className="w-4 h-4 object-contain" />;
87+
}
88+
if (suggestion.icon) {
89+
const iconClass = makeIconClass(suggestion.icon, true);
90+
const iconColor = suggestion.iconcolor;
91+
return <i className={iconClass} style={{ color: iconColor }} />;
92+
}
93+
if (suggestion.type === "url") {
94+
const iconClass = makeIconClass("globe", true);
95+
const iconColor = suggestion.iconcolor;
96+
return <i className={iconClass} style={{ color: iconColor }} />;
97+
} else if (suggestion.type === "file") {
98+
// For file suggestions, use the existing logic.
99+
const fullConfig = useAtomValue(atoms.fullConfigAtom);
100+
let icon: string = null;
101+
let iconColor: string = null;
102+
if (icon == null && suggestion["file:mimetype"] != null) {
103+
[icon, iconColor] = getMimeTypeIconAndColor(fullConfig, suggestion["file:mimetype"]);
104+
}
105+
const iconClass = makeIconClass(icon, true, { defaultIcon: "file" });
106+
return <i className={iconClass} style={{ color: iconColor }} />;
107+
}
108+
const iconClass = makeIconClass("file", true);
109+
return <i className={iconClass} />;
110+
};
111+
112+
const SuggestionContent: React.FC<{
113+
suggestion: SuggestionType;
114+
}> = ({ suggestion }) => {
115+
if (!isBlank(suggestion.subtext)) {
116+
return (
117+
<div className="flex flex-col">
118+
{/* Title on the first line, with highlighting */}
119+
<div className="truncate text-white">{highlightPositions(suggestion.display, suggestion.matchpos)}</div>
120+
{/* Subtext on the second line in a smaller, grey style */}
121+
<div className="truncate text-sm text-secondary">
122+
{highlightPositions(suggestion.subtext, suggestion.submatchpos)}
123+
</div>
124+
</div>
125+
);
126+
}
127+
return <span className="truncate">{highlightPositions(suggestion.display, suggestion.matchpos)}</span>;
128+
};
129+
130+
const BlockHeaderSuggestionControl: React.FC<BlockHeaderSuggestionControlProps> = (props) => {
131+
const [headerElem, setHeaderElem] = useState<HTMLElement>(null);
132+
const isOpen = useAtomValue(props.openAtom);
133+
134+
useEffect(() => {
135+
if (props.blockRef.current == null) {
136+
setHeaderElem(null);
137+
return;
138+
}
139+
const headerElem = props.blockRef.current.querySelector("[data-role='block-header']");
140+
setHeaderElem(headerElem as HTMLElement);
141+
}, [props.blockRef.current]);
142+
143+
const newClass = clsx(props.className, "rounded-t-none");
144+
return <SuggestionControl {...props} anchorRef={{ current: headerElem }} isOpen={isOpen} className={newClass} />;
145+
};
146+
147+
/**
148+
* The empty state component that can be used as a child of SuggestionControl.
149+
* If no children are provided to SuggestionControl, this default empty state will be used.
150+
*/
151+
const SuggestionControlNoResults: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
152+
return (
153+
<div className="flex items-center justify-center min-h-[120px] p-4">
154+
{children ?? <span className="text-gray-500">No Suggestions</span>}
155+
</div>
156+
);
157+
};
158+
159+
const SuggestionControlNoData: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
160+
return (
161+
<div className="flex items-center justify-center min-h-[120px] p-4">
162+
{children ?? <span className="text-gray-500">No Suggestions</span>}
163+
</div>
164+
);
165+
};
166+
167+
interface SuggestionControlInnerProps extends Omit<SuggestionControlProps, "isOpen"> {}
168+
169+
const SuggestionControlInner: React.FC<SuggestionControlInnerProps> = ({
170+
anchorRef,
171+
onClose,
172+
onSelect,
173+
onTab,
174+
fetchSuggestions,
175+
className,
176+
placeholderText,
177+
children,
178+
}) => {
179+
const widgetId = useId();
180+
const [query, setQuery] = useState("");
181+
const reqNumRef = useRef(0);
182+
let [suggestions, setSuggestions] = useState<SuggestionType[]>([]);
183+
const [selectedIndex, setSelectedIndex] = useState(0);
184+
const [fetched, setFetched] = useState(false);
185+
const inputRef = useRef<HTMLInputElement>(null);
186+
const dropdownRef = useRef<HTMLDivElement>(null);
187+
const { refs, floatingStyles, middlewareData } = useFloating({
188+
placement: "bottom",
189+
strategy: "absolute",
190+
middleware: [offset(-1)],
191+
});
192+
const emptyStateChild = React.Children.toArray(children).find(
193+
(child) => React.isValidElement(child) && child.type === SuggestionControlNoResults
194+
);
195+
const noDataChild = React.Children.toArray(children).find(
196+
(child) => React.isValidElement(child) && child.type === SuggestionControlNoData
197+
);
198+
199+
useEffect(() => {
200+
refs.setReference(anchorRef.current);
201+
}, [anchorRef.current]);
202+
203+
useEffect(() => {
204+
reqNumRef.current++;
205+
fetchSuggestions(query, { widgetid: widgetId, reqnum: reqNumRef.current }).then((results) => {
206+
if (results.reqnum !== reqNumRef.current) {
207+
return;
208+
}
209+
setSuggestions(results.suggestions ?? []);
210+
setFetched(true);
211+
});
212+
}, [query, fetchSuggestions]);
213+
214+
useEffect(() => {
215+
return () => {
216+
reqNumRef.current++;
217+
fetchSuggestions("", { widgetid: widgetId, reqnum: reqNumRef.current, dispose: true });
218+
};
219+
}, []);
220+
221+
useEffect(() => {
222+
inputRef.current?.focus();
223+
}, []);
224+
225+
useEffect(() => {
226+
const handleClickOutside = (event: MouseEvent) => {
227+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
228+
onClose();
229+
}
230+
};
231+
document.addEventListener("mousedown", handleClickOutside);
232+
return () => document.removeEventListener("mousedown", handleClickOutside);
233+
}, [onClose, anchorRef]);
234+
235+
const handleKeyDown = (e: React.KeyboardEvent) => {
236+
if (e.key === "ArrowDown") {
237+
e.preventDefault();
238+
setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
239+
} else if (e.key === "ArrowUp") {
240+
e.preventDefault();
241+
setSelectedIndex((prev) => Math.max(prev - 1, 0));
242+
} else if (e.key === "Enter" && selectedIndex >= 0) {
243+
e.preventDefault();
244+
onSelect(suggestions[selectedIndex], query);
245+
onClose();
246+
} else if (e.key === "Escape") {
247+
e.preventDefault();
248+
onClose();
249+
} else if (e.key === "Tab") {
250+
e.preventDefault();
251+
const suggestion = suggestions[selectedIndex];
252+
if (suggestion != null) {
253+
const tabResult = onTab?.(suggestion, query);
254+
if (tabResult != null) {
255+
setQuery(tabResult);
256+
}
257+
}
258+
}
259+
};
260+
return (
261+
<div
262+
className={clsx(
263+
"w-96 rounded-lg bg-modalbg shadow-lg border border-gray-700 z-[var(--zindex-typeahead-modal)] absolute",
264+
middlewareData?.offset == null ? "opacity-0" : null,
265+
className
266+
)}
267+
ref={refs.setFloating}
268+
style={floatingStyles}
269+
>
270+
<div className="p-2">
271+
<input
272+
ref={inputRef}
273+
type="text"
274+
value={query}
275+
onChange={(e) => {
276+
setQuery(e.target.value);
277+
setSelectedIndex(0);
278+
}}
279+
onKeyDown={handleKeyDown}
280+
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"
281+
placeholder={placeholderText}
282+
/>
283+
</div>
284+
{fetched &&
285+
(suggestions.length > 0 ? (
286+
<div ref={dropdownRef} className="max-h-96 overflow-y-auto divide-y divide-gray-700">
287+
{suggestions.map((suggestion, index) => (
288+
<div
289+
key={suggestion.suggestionid}
290+
className={clsx(
291+
"flex items-center gap-3 px-4 py-2 cursor-pointer",
292+
index === selectedIndex ? "bg-accentbg" : "hover:bg-hoverbg",
293+
"text-gray-100"
294+
)}
295+
onClick={() => {
296+
onSelect(suggestion, query);
297+
onClose();
298+
}}
299+
>
300+
<SuggestionIcon suggestion={suggestion} />
301+
<SuggestionContent suggestion={suggestion} />
302+
</div>
303+
))}
304+
</div>
305+
) : (
306+
// Render the empty state (either a provided child or the default)
307+
<div key="empty" className="flex items-center justify-center min-h-[120px] p-4">
308+
{query === ""
309+
? (noDataChild ?? <SuggestionControlNoData />)
310+
: (emptyStateChild ?? <SuggestionControlNoResults />)}
311+
</div>
312+
))}
313+
</div>
314+
);
315+
};
316+
317+
export { BlockHeaderSuggestionControl, SuggestionControl, SuggestionControlNoData, SuggestionControlNoResults };

0 commit comments

Comments
 (0)