mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
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:
parent
6de98ac3fb
commit
477052e8fc
@ -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 };
|
||||
}
|
||||
|
@ -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");
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
10
frontend/types/custom.d.ts
vendored
10
frontend/types/custom.d.ts
vendored
@ -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;
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user