Web Search (#1631)

Adds support for Cmd:f, Ctrl:f, and Alt:f to activate search in the Web
and Help widgets
This commit is contained in:
Evan Simkowitz 2024-12-29 12:58:11 -05:00 committed by GitHub
parent 6de98ac3fb
commit 477052e8fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 185 additions and 35 deletions

View File

@ -1,19 +1,18 @@
import { autoUpdate, FloatingPortal, Middleware, offset, useDismiss, useFloating } from "@floating-ui/react";
import clsx from "clsx";
import { atom, PrimitiveAtom, useAtom, useAtomValue } from "jotai";
import { memo, useCallback, useRef, useState } from "react";
import { atom, useAtom } from "jotai";
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { IconButton } from "./iconbutton";
import { Input } from "./input";
import "./search.scss";
type SearchProps = {
searchAtom: PrimitiveAtom<string>;
indexAtom: PrimitiveAtom<number>;
numResultsAtom: PrimitiveAtom<number>;
isOpenAtom: PrimitiveAtom<boolean>;
type SearchProps = SearchAtoms & {
anchorRef?: React.RefObject<HTMLElement>;
offsetX?: number;
offsetY?: number;
onSearch?: (search: string) => void;
onNext?: () => void;
onPrev?: () => void;
};
const SearchComponent = ({
@ -24,23 +23,54 @@ const SearchComponent = ({
anchorRef,
offsetX = 10,
offsetY = 10,
onSearch,
onNext,
onPrev,
}: SearchProps) => {
const [isOpen, setIsOpen] = useAtom(isOpenAtom);
const [search, setSearch] = useAtom(searchAtom);
const [index, setIndex] = useAtom(indexAtom);
const numResults = useAtomValue(numResultsAtom);
const [isOpen, setIsOpen] = useAtom<boolean>(isOpenAtom);
const [search, setSearch] = useAtom<string>(searchAtom);
const [index, setIndex] = useAtom<number>(indexAtom);
const [numResults, setNumResults] = useAtom<number>(numResultsAtom);
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open);
}, []);
useEffect(() => {
setSearch("");
setIndex(0);
setNumResults(0);
}, [isOpen]);
useEffect(() => {
setIndex(0);
setNumResults(0);
onSearch?.(search);
}, [search]);
const middleware: Middleware[] = [];
middleware.push(
offset(({ rects }) => ({
mainAxis: -rects.floating.height - offsetY,
crossAxis: -offsetX,
}))
const offsetCallback = useCallback(
({ rects }) => {
const docRect = document.documentElement.getBoundingClientRect();
let yOffsetCalc = -rects.floating.height - offsetY;
let xOffsetCalc = -offsetX;
const floatingBottom = rects.reference.y + rects.floating.height + offsetY;
const floatingLeft = rects.reference.x + rects.reference.width - (rects.floating.width + offsetX);
if (floatingBottom > docRect.bottom) {
yOffsetCalc -= docRect.bottom - floatingBottom;
}
if (floatingLeft < 5) {
xOffsetCalc += 5 - floatingLeft;
}
console.log("offsetCalc", yOffsetCalc, xOffsetCalc);
return {
mainAxis: yOffsetCalc,
crossAxis: xOffsetCalc,
};
},
[offsetX, offsetY]
);
middleware.push(offset(offsetCallback));
const { refs, floatingStyles, context } = useFloating({
placement: "top-end",
@ -55,26 +85,47 @@ const SearchComponent = ({
const dismiss = useDismiss(context);
const onPrevWrapper = useCallback(
() => (onPrev ? onPrev() : setIndex((index - 1) % numResults)),
[onPrev, index, numResults]
);
const onNextWrapper = useCallback(
() => (onNext ? onNext() : setIndex((index + 1) % numResults)),
[onNext, index, numResults]
);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
if (e.shiftKey) {
onPrevWrapper();
} else {
onNextWrapper();
}
e.preventDefault();
}
},
[onPrevWrapper, onNextWrapper, setIsOpen]
);
const prevDecl: IconButtonDecl = {
elemtype: "iconbutton",
icon: "chevron-up",
title: "Previous Result",
disabled: index === 0,
click: () => setIndex(index - 1),
title: "Previous Result (Shift+Enter)",
click: onPrevWrapper,
};
const nextDecl: IconButtonDecl = {
elemtype: "iconbutton",
icon: "chevron-down",
title: "Next Result",
disabled: !numResults || index === numResults - 1,
click: () => setIndex(index + 1),
title: "Next Result (Enter)",
click: onNextWrapper,
};
const closeDecl: IconButtonDecl = {
elemtype: "iconbutton",
icon: "xmark-large",
title: "Close",
title: "Close (Esc)",
click: () => setIsOpen(false),
};
@ -83,7 +134,13 @@ const SearchComponent = ({
{isOpen && (
<FloatingPortal>
<div className="search-container" style={{ ...floatingStyles }} {...dismiss} ref={refs.setFloating}>
<Input placeholder="Search" value={search} onChange={setSearch} />
<Input
placeholder="Search"
value={search}
onChange={setSearch}
onKeyDown={onKeyDown}
autoFocus
/>
<div
className={clsx("search-results", { hidden: numResults === 0 })}
aria-live="polite"
@ -105,11 +162,16 @@ const SearchComponent = ({
export const Search = memo(SearchComponent) as typeof SearchComponent;
export function useSearch(anchorRef?: React.RefObject<HTMLElement>): SearchProps {
const [searchAtom] = useState(atom(""));
const [indexAtom] = useState(atom(0));
const [numResultsAtom] = useState(atom(0));
const [isOpenAtom] = useState(atom(false));
export function useSearch(anchorRef?: React.RefObject<HTMLElement>, viewModel?: ViewModel): SearchProps {
const searchAtoms: SearchAtoms = useMemo(
() => ({ searchAtom: atom(""), indexAtom: atom(0), numResultsAtom: atom(0), isOpenAtom: atom(false) }),
[]
);
anchorRef ??= useRef(null);
return { searchAtom, indexAtom, numResultsAtom, isOpenAtom, anchorRef };
useEffect(() => {
if (viewModel) {
viewModel.searchAtoms = searchAtoms;
}
}, [viewModel]);
return { ...searchAtoms, anchorRef };
}

View File

@ -321,6 +321,28 @@ function registerGlobalKeys() {
return true;
});
}
function activateSearch(): boolean {
console.log("activateSearch");
const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
if (bcm.viewModel.searchAtoms) {
console.log("activateSearch2");
globalStore.set(bcm.viewModel.searchAtoms.isOpenAtom, true);
return true;
}
return false;
}
function deactivateSearch(): boolean {
console.log("deactivateSearch");
const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
if (bcm.viewModel.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpenAtom)) {
globalStore.set(bcm.viewModel.searchAtoms.isOpenAtom, false);
return true;
}
return false;
}
globalKeyMap.set("Cmd:f", activateSearch);
globalKeyMap.set("Ctrl:f", activateSearch);
globalKeyMap.set("Escape", deactivateSearch);
const allKeys = Array.from(globalKeyMap.keys());
// special case keys, handled by web view
allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft");

View File

@ -1,7 +1,8 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.webview {
.webview,
.webview-container {
height: 100%;
width: 100%;
border: none !important;

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import { BlockNodeModel } from "@/app/block/blocktypes";
import { Search, useSearch } from "@/app/element/search";
import { getApi, getBlockMetaKeyAtom, getSettingsKeyAtom, openLink } from "@/app/store/global";
import { getSimpleControlShiftAtom } from "@/app/store/keymodel";
import { ObjectService } from "@/app/store/services";
@ -12,8 +13,8 @@ import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"
import { fireAndForget } from "@/util/util";
import clsx from "clsx";
import { WebviewTag } from "electron";
import { Atom, PrimitiveAtom, atom, useAtomValue } from "jotai";
import { Fragment, createRef, memo, useEffect, useRef, useState } from "react";
import { Atom, PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai";
import { Fragment, createRef, memo, useCallback, useEffect, useRef, useState } from "react";
import "./webview.scss";
let webviewPreloadUrl = null;
@ -50,6 +51,7 @@ export class WebViewModel implements ViewModel {
mediaMuted: PrimitiveAtom<boolean>;
modifyExternalUrl?: (url: string) => string;
domReady: PrimitiveAtom<boolean>;
searchAtoms?: SearchAtoms;
constructor(blockId: string, nodeModel: BlockNodeModel) {
this.nodeModel = nodeModel;
@ -296,6 +298,9 @@ export class WebViewModel implements ViewModel {
handleNavigate(url: string) {
fireAndForget(() => ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { url }));
globalStore.set(this.url, url);
if (this.searchAtoms) {
globalStore.set(this.searchAtoms.isOpenAtom, false);
}
}
ensureUrlScheme(url: string, searchTemplate: string) {
@ -389,6 +394,11 @@ export class WebViewModel implements ViewModel {
}
giveFocus(): boolean {
console.log("webview giveFocus");
if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpenAtom)) {
console.log("search is open, not giving focus");
return true;
}
const ctrlShiftState = globalStore.get(getSimpleControlShiftAtom());
if (ctrlShiftState) {
// this is really weird, we don't get keyup events from webview
@ -537,6 +547,49 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
const metaUrlRef = useRef(metaUrl);
const zoomFactor = useAtomValue(getBlockMetaKeyAtom(model.blockId, "web:zoom")) || 1;
// Search
const searchProps = useSearch(model.webviewRef, model);
const searchVal = useAtomValue<string>(searchProps.searchAtom);
const setSearchIndex = useSetAtom(searchProps.indexAtom);
const setNumSearchResults = useSetAtom(searchProps.numResultsAtom);
const onSearch = useCallback((search: string) => {
try {
if (search) {
model.webviewRef.current?.findInPage(search, { findNext: true });
} else {
model.webviewRef.current?.stopFindInPage("clearSelection");
}
} catch (e) {
console.error("Failed to search", e);
}
}, []);
const onSearchNext = useCallback(() => {
try {
console.log("search next", searchVal);
model.webviewRef.current?.findInPage(searchVal, { findNext: false, forward: true });
} catch (e) {
console.error("Failed to search next", e);
}
}, [searchVal]);
const onSearchPrev = useCallback(() => {
try {
console.log("search prev", searchVal);
model.webviewRef.current?.findInPage(searchVal, { findNext: false, forward: false });
} catch (e) {
console.error("Failed to search prev", e);
}
}, [searchVal]);
const onFoundInPage = useCallback((event: any) => {
const result = event.result;
console.log("found in page", result);
if (!result) {
return;
}
setNumSearchResults(result.matches);
setSearchIndex(result.activeMatchOrdinal - 1);
}, []);
// End Search
// The initial value of the block metadata URL when the component first renders. Used to set the starting src value for the webview.
const [metaUrlInitial] = useState(metaUrl);
@ -669,6 +722,7 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
webview.addEventListener("dom-ready", handleDomReady);
webview.addEventListener("media-started-playing", handleMediaPlaying);
webview.addEventListener("media-paused", handleMediaPaused);
webview.addEventListener("found-in-page", onFoundInPage);
// Clean up event listeners on component unmount
return () => {
@ -684,6 +738,7 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
webview.removeEventListener("dom-ready", handleDomReady);
webview.removeEventListener("media-started-playing", handleMediaPlaying);
webview.removeEventListener("media-paused", handleMediaPaused);
webview.removeEventListener("found-in-page", onFoundInPage);
};
}, []);
@ -705,6 +760,7 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
<div>{errorText}</div>
</div>
)}
<Search {...searchProps} onSearch={onSearch} onNext={onSearchNext} onPrev={onSearchPrev} />
</Fragment>
);
});

View File

@ -228,6 +228,13 @@ declare global {
elemtype: "menubutton";
} & MenuButtonProps;
type SearchAtoms = {
searchAtom: PrimitiveAtom<string>;
indexAtom: PrimitiveAtom<number>;
numResultsAtom: PrimitiveAtom<number>;
isOpenAtom: PrimitiveAtom<boolean>;
};
interface ViewModel {
viewType: string;
viewIcon?: jotai.Atom<string | IconButtonDecl>;
@ -239,11 +246,10 @@ declare global {
manageConnection?: jotai.Atom<boolean>;
noPadding?: jotai.Atom<boolean>;
filterOutNowsh?: jotai.Atom<boolean>;
searchAtoms?: SearchAtoms;
onBack?: () => void;
onForward?: () => void;
onSearchChange?: (text: string) => void;
onSearch?: (text: string) => void;
getSettingsMenuItems?: () => ContextMenuItem[];
giveFocus?: () => boolean;
keyDownHandler?: (e: WaveKeyboardEvent) => boolean;

View File

@ -78,6 +78,9 @@ function parseKeyDescription(keyDescription: string): KeyPressDecl {
rtn.mods.Option = true;
}
rtn.mods.Meta = true;
} else if (key == "Esc") {
rtn.key = "Escape";
rtn.keyType = KeyTypeKey;
} else {
let { key: parsedKey, type: keyType } = parseKey(key);
rtn.key = parsedKey;