Skip to content

Commit 15b4bb5

Browse files
authored
Web Search (wavetermdev#1631)
Adds support for Cmd:f, Ctrl:f, and Alt:f to activate search in the Web and Help widgets
1 parent 5ca9db9 commit 15b4bb5

File tree

6 files changed

+185
-35
lines changed

6 files changed

+185
-35
lines changed

frontend/app/element/search.tsx

Lines changed: 92 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
import { autoUpdate, FloatingPortal, Middleware, offset, useDismiss, useFloating } from "@floating-ui/react";
22
import clsx from "clsx";
3-
import { atom, PrimitiveAtom, useAtom, useAtomValue } from "jotai";
4-
import { memo, useCallback, useRef, useState } from "react";
3+
import { atom, useAtom } from "jotai";
4+
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
55
import { IconButton } from "./iconbutton";
66
import { Input } from "./input";
77
import "./search.scss";
88

9-
type SearchProps = {
10-
searchAtom: PrimitiveAtom<string>;
11-
indexAtom: PrimitiveAtom<number>;
12-
numResultsAtom: PrimitiveAtom<number>;
13-
isOpenAtom: PrimitiveAtom<boolean>;
9+
type SearchProps = SearchAtoms & {
1410
anchorRef?: React.RefObject<HTMLElement>;
1511
offsetX?: number;
1612
offsetY?: number;
13+
onSearch?: (search: string) => void;
14+
onNext?: () => void;
15+
onPrev?: () => void;
1716
};
1817

1918
const SearchComponent = ({
@@ -24,23 +23,54 @@ const SearchComponent = ({
2423
anchorRef,
2524
offsetX = 10,
2625
offsetY = 10,
26+
onSearch,
27+
onNext,
28+
onPrev,
2729
}: SearchProps) => {
28-
const [isOpen, setIsOpen] = useAtom(isOpenAtom);
29-
const [search, setSearch] = useAtom(searchAtom);
30-
const [index, setIndex] = useAtom(indexAtom);
31-
const numResults = useAtomValue(numResultsAtom);
30+
const [isOpen, setIsOpen] = useAtom<boolean>(isOpenAtom);
31+
const [search, setSearch] = useAtom<string>(searchAtom);
32+
const [index, setIndex] = useAtom<number>(indexAtom);
33+
const [numResults, setNumResults] = useAtom<number>(numResultsAtom);
3234

3335
const handleOpenChange = useCallback((open: boolean) => {
3436
setIsOpen(open);
3537
}, []);
3638

39+
useEffect(() => {
40+
setSearch("");
41+
setIndex(0);
42+
setNumResults(0);
43+
}, [isOpen]);
44+
45+
useEffect(() => {
46+
setIndex(0);
47+
setNumResults(0);
48+
onSearch?.(search);
49+
}, [search]);
50+
3751
const middleware: Middleware[] = [];
38-
middleware.push(
39-
offset(({ rects }) => ({
40-
mainAxis: -rects.floating.height - offsetY,
41-
crossAxis: -offsetX,
42-
}))
52+
const offsetCallback = useCallback(
53+
({ rects }) => {
54+
const docRect = document.documentElement.getBoundingClientRect();
55+
let yOffsetCalc = -rects.floating.height - offsetY;
56+
let xOffsetCalc = -offsetX;
57+
const floatingBottom = rects.reference.y + rects.floating.height + offsetY;
58+
const floatingLeft = rects.reference.x + rects.reference.width - (rects.floating.width + offsetX);
59+
if (floatingBottom > docRect.bottom) {
60+
yOffsetCalc -= docRect.bottom - floatingBottom;
61+
}
62+
if (floatingLeft < 5) {
63+
xOffsetCalc += 5 - floatingLeft;
64+
}
65+
console.log("offsetCalc", yOffsetCalc, xOffsetCalc);
66+
return {
67+
mainAxis: yOffsetCalc,
68+
crossAxis: xOffsetCalc,
69+
};
70+
},
71+
[offsetX, offsetY]
4372
);
73+
middleware.push(offset(offsetCallback));
4474

4575
const { refs, floatingStyles, context } = useFloating({
4676
placement: "top-end",
@@ -55,26 +85,47 @@ const SearchComponent = ({
5585

5686
const dismiss = useDismiss(context);
5787

88+
const onPrevWrapper = useCallback(
89+
() => (onPrev ? onPrev() : setIndex((index - 1) % numResults)),
90+
[onPrev, index, numResults]
91+
);
92+
const onNextWrapper = useCallback(
93+
() => (onNext ? onNext() : setIndex((index + 1) % numResults)),
94+
[onNext, index, numResults]
95+
);
96+
97+
const onKeyDown = useCallback(
98+
(e: React.KeyboardEvent) => {
99+
if (e.key === "Enter") {
100+
if (e.shiftKey) {
101+
onPrevWrapper();
102+
} else {
103+
onNextWrapper();
104+
}
105+
e.preventDefault();
106+
}
107+
},
108+
[onPrevWrapper, onNextWrapper, setIsOpen]
109+
);
110+
58111
const prevDecl: IconButtonDecl = {
59112
elemtype: "iconbutton",
60113
icon: "chevron-up",
61-
title: "Previous Result",
62-
disabled: index === 0,
63-
click: () => setIndex(index - 1),
114+
title: "Previous Result (Shift+Enter)",
115+
click: onPrevWrapper,
64116
};
65117

66118
const nextDecl: IconButtonDecl = {
67119
elemtype: "iconbutton",
68120
icon: "chevron-down",
69-
title: "Next Result",
70-
disabled: !numResults || index === numResults - 1,
71-
click: () => setIndex(index + 1),
121+
title: "Next Result (Enter)",
122+
click: onNextWrapper,
72123
};
73124

74125
const closeDecl: IconButtonDecl = {
75126
elemtype: "iconbutton",
76127
icon: "xmark-large",
77-
title: "Close",
128+
title: "Close (Esc)",
78129
click: () => setIsOpen(false),
79130
};
80131

@@ -83,7 +134,13 @@ const SearchComponent = ({
83134
{isOpen && (
84135
<FloatingPortal>
85136
<div className="search-container" style={{ ...floatingStyles }} {...dismiss} ref={refs.setFloating}>
86-
<Input placeholder="Search" value={search} onChange={setSearch} />
137+
<Input
138+
placeholder="Search"
139+
value={search}
140+
onChange={setSearch}
141+
onKeyDown={onKeyDown}
142+
autoFocus
143+
/>
87144
<div
88145
className={clsx("search-results", { hidden: numResults === 0 })}
89146
aria-live="polite"
@@ -105,11 +162,16 @@ const SearchComponent = ({
105162

106163
export const Search = memo(SearchComponent) as typeof SearchComponent;
107164

108-
export function useSearch(anchorRef?: React.RefObject<HTMLElement>): SearchProps {
109-
const [searchAtom] = useState(atom(""));
110-
const [indexAtom] = useState(atom(0));
111-
const [numResultsAtom] = useState(atom(0));
112-
const [isOpenAtom] = useState(atom(false));
165+
export function useSearch(anchorRef?: React.RefObject<HTMLElement>, viewModel?: ViewModel): SearchProps {
166+
const searchAtoms: SearchAtoms = useMemo(
167+
() => ({ searchAtom: atom(""), indexAtom: atom(0), numResultsAtom: atom(0), isOpenAtom: atom(false) }),
168+
[]
169+
);
113170
anchorRef ??= useRef(null);
114-
return { searchAtom, indexAtom, numResultsAtom, isOpenAtom, anchorRef };
171+
useEffect(() => {
172+
if (viewModel) {
173+
viewModel.searchAtoms = searchAtoms;
174+
}
175+
}, [viewModel]);
176+
return { ...searchAtoms, anchorRef };
115177
}

frontend/app/store/keymodel.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,28 @@ function registerGlobalKeys() {
321321
return true;
322322
});
323323
}
324+
function activateSearch(): boolean {
325+
console.log("activateSearch");
326+
const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
327+
if (bcm.viewModel.searchAtoms) {
328+
console.log("activateSearch2");
329+
globalStore.set(bcm.viewModel.searchAtoms.isOpenAtom, true);
330+
return true;
331+
}
332+
return false;
333+
}
334+
function deactivateSearch(): boolean {
335+
console.log("deactivateSearch");
336+
const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
337+
if (bcm.viewModel.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpenAtom)) {
338+
globalStore.set(bcm.viewModel.searchAtoms.isOpenAtom, false);
339+
return true;
340+
}
341+
return false;
342+
}
343+
globalKeyMap.set("Cmd:f", activateSearch);
344+
globalKeyMap.set("Ctrl:f", activateSearch);
345+
globalKeyMap.set("Escape", deactivateSearch);
324346
const allKeys = Array.from(globalKeyMap.keys());
325347
// special case keys, handled by web view
326348
allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft");

frontend/app/view/webview/webview.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright 2024, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
.webview {
4+
.webview,
5+
.webview-container {
56
height: 100%;
67
width: 100%;
78
border: none !important;

frontend/app/view/webview/webview.tsx

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { BlockNodeModel } from "@/app/block/blocktypes";
5+
import { Search, useSearch } from "@/app/element/search";
56
import { getApi, getBlockMetaKeyAtom, getSettingsKeyAtom, openLink } from "@/app/store/global";
67
import { getSimpleControlShiftAtom } from "@/app/store/keymodel";
78
import { ObjectService } from "@/app/store/services";
@@ -12,8 +13,8 @@ import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"
1213
import { fireAndForget } from "@/util/util";
1314
import clsx from "clsx";
1415
import { WebviewTag } from "electron";
15-
import { Atom, PrimitiveAtom, atom, useAtomValue } from "jotai";
16-
import { Fragment, createRef, memo, useEffect, useRef, useState } from "react";
16+
import { Atom, PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai";
17+
import { Fragment, createRef, memo, useCallback, useEffect, useRef, useState } from "react";
1718
import "./webview.scss";
1819

1920
let webviewPreloadUrl = null;
@@ -50,6 +51,7 @@ export class WebViewModel implements ViewModel {
5051
mediaMuted: PrimitiveAtom<boolean>;
5152
modifyExternalUrl?: (url: string) => string;
5253
domReady: PrimitiveAtom<boolean>;
54+
searchAtoms?: SearchAtoms;
5355

5456
constructor(blockId: string, nodeModel: BlockNodeModel) {
5557
this.nodeModel = nodeModel;
@@ -296,6 +298,9 @@ export class WebViewModel implements ViewModel {
296298
handleNavigate(url: string) {
297299
fireAndForget(() => ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { url }));
298300
globalStore.set(this.url, url);
301+
if (this.searchAtoms) {
302+
globalStore.set(this.searchAtoms.isOpenAtom, false);
303+
}
299304
}
300305

301306
ensureUrlScheme(url: string, searchTemplate: string) {
@@ -389,6 +394,11 @@ export class WebViewModel implements ViewModel {
389394
}
390395

391396
giveFocus(): boolean {
397+
console.log("webview giveFocus");
398+
if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpenAtom)) {
399+
console.log("search is open, not giving focus");
400+
return true;
401+
}
392402
const ctrlShiftState = globalStore.get(getSimpleControlShiftAtom());
393403
if (ctrlShiftState) {
394404
// this is really weird, we don't get keyup events from webview
@@ -537,6 +547,49 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
537547
const metaUrlRef = useRef(metaUrl);
538548
const zoomFactor = useAtomValue(getBlockMetaKeyAtom(model.blockId, "web:zoom")) || 1;
539549

550+
// Search
551+
const searchProps = useSearch(model.webviewRef, model);
552+
const searchVal = useAtomValue<string>(searchProps.searchAtom);
553+
const setSearchIndex = useSetAtom(searchProps.indexAtom);
554+
const setNumSearchResults = useSetAtom(searchProps.numResultsAtom);
555+
const onSearch = useCallback((search: string) => {
556+
try {
557+
if (search) {
558+
model.webviewRef.current?.findInPage(search, { findNext: true });
559+
} else {
560+
model.webviewRef.current?.stopFindInPage("clearSelection");
561+
}
562+
} catch (e) {
563+
console.error("Failed to search", e);
564+
}
565+
}, []);
566+
const onSearchNext = useCallback(() => {
567+
try {
568+
console.log("search next", searchVal);
569+
model.webviewRef.current?.findInPage(searchVal, { findNext: false, forward: true });
570+
} catch (e) {
571+
console.error("Failed to search next", e);
572+
}
573+
}, [searchVal]);
574+
const onSearchPrev = useCallback(() => {
575+
try {
576+
console.log("search prev", searchVal);
577+
model.webviewRef.current?.findInPage(searchVal, { findNext: false, forward: false });
578+
} catch (e) {
579+
console.error("Failed to search prev", e);
580+
}
581+
}, [searchVal]);
582+
const onFoundInPage = useCallback((event: any) => {
583+
const result = event.result;
584+
console.log("found in page", result);
585+
if (!result) {
586+
return;
587+
}
588+
setNumSearchResults(result.matches);
589+
setSearchIndex(result.activeMatchOrdinal - 1);
590+
}, []);
591+
// End Search
592+
540593
// The initial value of the block metadata URL when the component first renders. Used to set the starting src value for the webview.
541594
const [metaUrlInitial] = useState(metaUrl);
542595

@@ -669,6 +722,7 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
669722
webview.addEventListener("dom-ready", handleDomReady);
670723
webview.addEventListener("media-started-playing", handleMediaPlaying);
671724
webview.addEventListener("media-paused", handleMediaPaused);
725+
webview.addEventListener("found-in-page", onFoundInPage);
672726

673727
// Clean up event listeners on component unmount
674728
return () => {
@@ -684,6 +738,7 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
684738
webview.removeEventListener("dom-ready", handleDomReady);
685739
webview.removeEventListener("media-started-playing", handleMediaPlaying);
686740
webview.removeEventListener("media-paused", handleMediaPaused);
741+
webview.removeEventListener("found-in-page", onFoundInPage);
687742
};
688743
}, []);
689744

@@ -705,6 +760,7 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
705760
<div>{errorText}</div>
706761
</div>
707762
)}
763+
<Search {...searchProps} onSearch={onSearch} onNext={onSearchNext} onPrev={onSearchPrev} />
708764
</Fragment>
709765
);
710766
});

frontend/types/custom.d.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,13 @@ declare global {
228228
elemtype: "menubutton";
229229
} & MenuButtonProps;
230230

231+
type SearchAtoms = {
232+
searchAtom: PrimitiveAtom<string>;
233+
indexAtom: PrimitiveAtom<number>;
234+
numResultsAtom: PrimitiveAtom<number>;
235+
isOpenAtom: PrimitiveAtom<boolean>;
236+
};
237+
231238
interface ViewModel {
232239
viewType: string;
233240
viewIcon?: jotai.Atom<string | IconButtonDecl>;
@@ -239,11 +246,10 @@ declare global {
239246
manageConnection?: jotai.Atom<boolean>;
240247
noPadding?: jotai.Atom<boolean>;
241248
filterOutNowsh?: jotai.Atom<boolean>;
249+
searchAtoms?: SearchAtoms;
242250

243251
onBack?: () => void;
244252
onForward?: () => void;
245-
onSearchChange?: (text: string) => void;
246-
onSearch?: (text: string) => void;
247253
getSettingsMenuItems?: () => ContextMenuItem[];
248254
giveFocus?: () => boolean;
249255
keyDownHandler?: (e: WaveKeyboardEvent) => boolean;

frontend/util/keyutil.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ function parseKeyDescription(keyDescription: string): KeyPressDecl {
7878
rtn.mods.Option = true;
7979
}
8080
rtn.mods.Meta = true;
81+
} else if (key == "Esc") {
82+
rtn.key = "Escape";
83+
rtn.keyType = KeyTypeKey;
8184
} else {
8285
let { key: parsedKey, type: keyType } = parseKey(key);
8386
rtn.key = parsedKey;

0 commit comments

Comments
 (0)