clean up additional button logic, add toggleiconbutton component

This commit is contained in:
Evan Simkowitz 2024-12-29 21:19:11 -05:00
parent 4533c813ce
commit b0ed9d932a
No known key found for this signature in database
10 changed files with 232 additions and 113 deletions

View File

@ -30,7 +30,7 @@ import {
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { ErrorBoundary } from "@/element/errorboundary";
import { IconButton } from "@/element/iconbutton";
import { IconButton, ToggleIconButton } from "@/element/iconbutton";
import { MagnifyIcon } from "@/element/magnify";
import { MenuButton } from "@/element/menubutton";
import { NodeModel } from "@/layout/index";
@ -278,6 +278,8 @@ const BlockFrame_Header = ({
const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; preview: boolean }) => {
if (elem.elemtype == "iconbutton") {
return <IconButton decl={elem} className={clsx("block-frame-header-iconbutton", elem.className)} />;
} else if (elem.elemtype == "toggleiconbutton") {
return <ToggleIconButton decl={elem} className={clsx("block-frame-header-iconbutton", elem.className)} />;
} else if (elem.elemtype == "input") {
return <Input decl={elem} className={clsx("block-frame-input", elem.className)} preview={preview} />;
} else if (elem.elemtype == "text") {

View File

@ -32,4 +32,17 @@
cursor: default;
opacity: 0.45 !important;
}
&.toggle {
border-radius: 3px;
padding: 1px;
&.active {
opacity: 1;
border: 1px solid var(--accent-color);
padding: 0;
}
&:hover {
background: var(--highlight-bg-color);
}
}
}

View File

@ -4,7 +4,8 @@
import { useLongClick } from "@/app/hook/useLongClick";
import { makeIconClass } from "@/util/util";
import clsx from "clsx";
import { forwardRef, memo, useRef } from "react";
import { atom, useAtom } from "jotai";
import { forwardRef, memo, useMemo, useRef } from "react";
import "./iconbutton.scss";
type IconButtonProps = { decl: IconButtonDecl; className?: string };
@ -21,6 +22,7 @@ export const IconButton = memo(
"no-action": decl.noAction,
})}
title={decl.title}
aria-label={decl.title}
style={{ color: decl.iconColor ?? "inherit" }}
>
{typeof decl.icon === "string" ? <i className={makeIconClass(decl.icon, true, { spin })} /> : decl.icon}
@ -28,3 +30,34 @@ export const IconButton = memo(
);
})
);
type ToggleIconButtonProps = { decl: ToggleIconButtonDecl; className?: string };
export const ToggleIconButton = memo(
forwardRef<HTMLButtonElement, ToggleIconButtonProps>(({ decl, className }, ref) => {
const activeAtom = useMemo(() => decl.active ?? atom(false), [decl.active]);
const [active, setActive] = useAtom(activeAtom);
ref = ref ?? useRef<HTMLButtonElement>(null);
const spin = decl.iconSpin ?? false;
const title = `${decl.title}${active ? " (Active)" : ""}`;
return (
<button
ref={ref}
className={clsx("wave-iconbutton", "toggle", className, decl.className, {
active,
disabled: decl.disabled,
"no-action": decl.noAction,
})}
title={title}
aria-label={title}
style={{ color: decl.iconColor ?? "inherit" }}
onPointerDown={() => {
console.log("active", active);
setActive(!active);
}}
>
{typeof decl.icon === "string" ? <i className={makeIconClass(decl.icon, true, { spin })} /> : decl.icon}
</button>
);
})
);

View File

@ -34,7 +34,7 @@
}
}
.right-buttons {
.right-buttons:not(:empty) {
display: flex;
gap: 5px;
padding-left: 5px;
@ -42,5 +42,15 @@
button {
font-size: 12px;
}
&.additional {
gap: 2px;
button {
font-size: 10px;
i {
margin: auto 1px;
}
}
}
}
}

View File

@ -16,10 +16,35 @@ const meta: Meta<typeof Search> = {
export default meta;
type Story = StoryObj<typeof Popover>;
export const DefaultSearch: Story = {
export const Default: Story = {
render: (args) => {
const props = useSearch();
const setIsOpen = useSetAtom(props.isOpenAtom);
const setIsOpen = useSetAtom(props.isOpen);
useEffect(() => {
setIsOpen(true);
}, []);
return (
<div
className="viewbox"
ref={props.anchorRef as React.RefObject<HTMLDivElement>}
style={{
border: "2px solid black",
width: "100%",
height: "200px",
background: "var(--main-bg-color)",
}}
>
<Search {...args} {...props} />
</div>
);
},
args: {},
};
export const AdditionalButtons: Story = {
render: (args) => {
const props = useSearch({ regex: true, caseSensitive: true, wholeWord: true });
const setIsOpen = useSetAtom(props.isOpen);
useEffect(() => {
setIsOpen(true);
}, []);
@ -44,8 +69,8 @@ export const DefaultSearch: Story = {
export const Results10: Story = {
render: (args) => {
const props = useSearch();
const setIsOpen = useSetAtom(props.isOpenAtom);
const setNumResults = useSetAtom(props.numResultsAtom);
const setIsOpen = useSetAtom(props.isOpen);
const setNumResults = useSetAtom(props.resultsCount);
useEffect(() => {
setIsOpen(true);
setNumResults(10);
@ -71,9 +96,9 @@ export const Results10: Story = {
export const InputAndResults10: Story = {
render: (args) => {
const props = useSearch();
const setIsOpen = useSetAtom(props.isOpenAtom);
const setNumResults = useSetAtom(props.numResultsAtom);
const setSearch = useSetAtom(props.searchAtom);
const setIsOpen = useSetAtom(props.isOpen);
const setNumResults = useSetAtom(props.resultsCount);
const setSearch = useSetAtom(props.searchValue);
useEffect(() => {
setIsOpen(true);
setNumResults(10);
@ -100,9 +125,9 @@ export const InputAndResults10: Story = {
export const LongInputAndResults10: Story = {
render: (args) => {
const props = useSearch();
const setIsOpen = useSetAtom(props.isOpenAtom);
const setNumResults = useSetAtom(props.numResultsAtom);
const setSearch = useSetAtom(props.searchAtom);
const setIsOpen = useSetAtom(props.isOpen);
const setNumResults = useSetAtom(props.resultsCount);
const setSearch = useSetAtom(props.searchValue);
useEffect(() => {
setIsOpen(true);
setNumResults(10);

View File

@ -5,7 +5,7 @@ import { autoUpdate, FloatingPortal, Middleware, offset, useFloating } from "@fl
import clsx from "clsx";
import { atom, useAtom } from "jotai";
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { IconButton } from "./iconbutton";
import { IconButton, ToggleIconButton } from "./iconbutton";
import { Input } from "./input";
import "./search.scss";
@ -16,21 +16,22 @@ type SearchProps = SearchAtoms & {
onSearch?: (search: string) => void;
onNext?: () => void;
onPrev?: () => void;
additionalButtons?: IconButtonDecl[];
};
const SearchComponent = ({
searchAtom,
indexAtom,
numResultsAtom,
isOpenAtom,
searchValue: searchAtom,
resultsIndex: indexAtom,
resultsCount: numResultsAtom,
regex: regexAtom,
caseSensitive: caseSensitiveAtom,
wholeWord: wholeWordAtom,
isOpen: isOpenAtom,
anchorRef,
offsetX = 10,
offsetY = 10,
onSearch,
onNext,
onPrev,
additionalButtons,
}: SearchProps) => {
const [isOpen, setIsOpen] = useAtom<boolean>(isOpenAtom);
const [search, setSearch] = useAtom<string>(searchAtom);
@ -131,6 +132,31 @@ const SearchComponent = ({
click: () => setIsOpen(false),
};
const regexDecl: ToggleIconButtonDecl = regexAtom
? {
elemtype: "toggleiconbutton",
icon: "asterisk",
title: "Regex Search",
active: regexAtom,
}
: null;
const wholeWordDecl: ToggleIconButtonDecl = caseSensitiveAtom
? {
elemtype: "toggleiconbutton",
icon: "w",
title: "Whole Word",
active: wholeWordAtom,
}
: null;
const caseSensitiveDecl: ToggleIconButtonDecl = caseSensitiveAtom
? {
elemtype: "toggleiconbutton",
icon: "font-case",
title: "Case Sensitive",
active: caseSensitiveAtom,
}
: null;
return (
<>
{isOpen && (
@ -150,13 +176,13 @@ const SearchComponent = ({
>
{index + 1}/{numResults}
</div>
{additionalButtons?.length && (
<div className="right-buttons">
{additionalButtons.map((decl, i) => (
<IconButton key={i} decl={decl} />
))}
</div>
)}
<div className="right-buttons additional">
{caseSensitiveDecl && <ToggleIconButton decl={caseSensitiveDecl} />}
{wholeWordDecl && <ToggleIconButton decl={wholeWordDecl} />}
{regexDecl && <ToggleIconButton decl={regexDecl} />}
</div>
<div className="right-buttons">
<IconButton decl={prevDecl} />
<IconButton decl={nextDecl} />
@ -171,16 +197,32 @@ const SearchComponent = ({
export const Search = memo(SearchComponent) as typeof SearchComponent;
export function useSearch(anchorRef?: React.RefObject<HTMLElement>, viewModel?: ViewModel): SearchProps {
type SearchOptions = {
anchorRef?: React.RefObject<HTMLElement>;
viewModel?: ViewModel;
regex?: boolean;
caseSensitive?: boolean;
wholeWord?: boolean;
};
export function useSearch(options?: SearchOptions): SearchProps {
const searchAtoms: SearchAtoms = useMemo(
() => ({ searchAtom: atom(""), indexAtom: atom(0), numResultsAtom: atom(0), isOpenAtom: atom(false) }),
() => ({
searchValue: atom(""),
resultsIndex: atom(0),
resultsCount: atom(0),
isOpen: atom(false),
regex: options?.regex !== undefined ? atom(options.regex) : undefined,
caseSensitive: options?.caseSensitive !== undefined ? atom(options.caseSensitive) : undefined,
wholeWord: options?.wholeWord !== undefined ? atom(options.wholeWord) : undefined,
}),
[]
);
anchorRef ??= useRef(null);
const anchorRef = options?.anchorRef ?? useRef(null);
useEffect(() => {
if (viewModel) {
viewModel.searchAtoms = searchAtoms;
if (options?.viewModel) {
options.viewModel.searchAtoms = searchAtoms;
}
}, [viewModel]);
}, [options?.viewModel]);
return { ...searchAtoms, anchorRef };
}

View File

@ -326,7 +326,7 @@ function registerGlobalKeys() {
const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
if (bcm.viewModel.searchAtoms) {
console.log("activateSearch2");
globalStore.set(bcm.viewModel.searchAtoms.isOpenAtom, true);
globalStore.set(bcm.viewModel.searchAtoms.isOpen, true);
return true;
}
return false;
@ -334,8 +334,8 @@ function registerGlobalKeys() {
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);
if (bcm.viewModel.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpen)) {
globalStore.set(bcm.viewModel.searchAtoms.isOpen, false);
return true;
}
return false;

View File

@ -25,7 +25,7 @@ import {
} from "@/store/global";
import * as services from "@/store/services";
import * as keyutil from "@/util/keyutil";
import { boundNumber, fireAndForget } from "@/util/util";
import { boundNumber, fireAndForget, useAtomValueSafe } from "@/util/util";
import { ISearchOptions } from "@xterm/addon-search";
import clsx from "clsx";
import debug from "debug";
@ -364,7 +364,7 @@ class TermViewModel implements ViewModel {
}
giveFocus(): boolean {
if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpenAtom)) {
if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) {
console.log("search is open, not giving focus");
return true;
}
@ -793,72 +793,54 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
const connFontFamily = fullConfig.connections?.[blockData?.meta?.connection]?.["term:fontfamily"];
// search
const searchProps = useSearch(viewRef, model);
const { additionalButtons, stateAtoms } = React.useMemo(() => {
const regexAtom = jotai.atom(false);
const wholeWordAtom = jotai.atom(false);
const caseSensitiveAtom = jotai.atom(false);
const additionalButtons: IconButtonDecl[] = [
{
elemtype: "iconbutton",
icon: "font-case",
title: "Case Sensitive",
click: () => {
globalStore.set(caseSensitiveAtom, !globalStore.get(caseSensitiveAtom));
},
const searchProps = useSearch({
anchorRef: viewRef,
viewModel: model,
caseSensitive: false,
wholeWord: false,
regex: false,
});
const caseSensitive = useAtomValueSafe<boolean>(searchProps.caseSensitive);
const wholeWord = useAtomValueSafe<boolean>(searchProps.wholeWord);
const regex = useAtomValueSafe<boolean>(searchProps.regex);
const searchVal = jotai.useAtomValue<string>(searchProps.searchValue);
const searchOpts: ISearchOptions = React.useMemo(
() => ({
incremental: true,
regex,
wholeWord,
caseSensitive,
decorations: {
matchOverviewRuler: "#e0e0e0",
activeMatchColorOverviewRuler: "#e0e0e0",
activeMatchBorder: "#58c142",
matchBorder: "#e0e0e0",
},
{
elemtype: "iconbutton",
icon: "w",
title: "Whole Word",
click: () => {
globalStore.set(wholeWordAtom, !globalStore.get(wholeWordAtom));
},
},
{
elemtype: "iconbutton",
icon: "asterisk",
title: "Regex Search",
click: () => {
globalStore.set(regexAtom, !globalStore.get(regexAtom));
},
},
];
return {
additionalButtons,
stateAtoms: { caseSensitiveAtom, wholeWordAtom, regexAtom },
};
}, []);
searchProps.additionalButtons = additionalButtons;
const caseSensitive = jotai.useAtomValue<boolean>(stateAtoms.caseSensitiveAtom);
const wholeWord = jotai.useAtomValue<boolean>(stateAtoms.wholeWordAtom);
const regex = jotai.useAtomValue<boolean>(stateAtoms.regexAtom);
const searchVal = jotai.useAtomValue<string>(searchProps.searchAtom);
const searchOpts: ISearchOptions = {
incremental: true,
regex,
wholeWord,
caseSensitive,
decorations: {
matchOverviewRuler: "#e0e0e0",
activeMatchColorOverviewRuler: "#e0e0e0",
activeMatchBorder: "#58c142",
matchBorder: "#e0e0e0",
}),
[regex, wholeWord, caseSensitive]
);
searchProps.onSearch = React.useCallback(
(searchText: string) => {
if (searchText == "") {
model.termRef.current?.searchAddon.clearDecorations();
return;
}
model.termRef.current?.searchAddon.findNext(searchText, searchOpts);
},
};
searchProps.onSearch = React.useCallback((searchText: string) => {
if (searchText == "") {
model.termRef.current?.searchAddon.clearDecorations();
return;
}
model.termRef.current?.searchAddon.findNext(searchText, searchOpts);
}, []);
[searchOpts]
);
searchProps.onPrev = React.useCallback(() => {
model.termRef.current?.searchAddon.findPrevious(searchVal, searchOpts);
}, [searchVal]);
}, [searchVal, searchOpts]);
searchProps.onNext = React.useCallback(() => {
model.termRef.current?.searchAddon.findNext(searchVal, searchOpts);
}, [searchVal]);
}, [searchVal, searchOpts]);
// rerun search when the searchOpts change
React.useEffect(() => {
model.termRef.current?.searchAddon.clearDecorations();
searchProps.onSearch(searchVal);
}, [searchOpts]);
// end search
React.useEffect(() => {
@ -906,8 +888,8 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
});
rszObs.observe(connectElemRef.current);
termWrap.onSearchResultsDidChange = (results) => {
globalStore.set(searchProps.indexAtom, results.resultIndex);
globalStore.set(searchProps.numResultsAtom, results.resultCount);
globalStore.set(searchProps.resultsIndex, results.resultIndex);
globalStore.set(searchProps.resultsCount, results.resultCount);
};
fireAndForget(termWrap.initTerminal.bind(termWrap));
if (wasFocused) {

View File

@ -299,7 +299,7 @@ export class WebViewModel implements ViewModel {
fireAndForget(() => ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { url }));
globalStore.set(this.url, url);
if (this.searchAtoms) {
globalStore.set(this.searchAtoms.isOpenAtom, false);
globalStore.set(this.searchAtoms.isOpen, false);
}
}
@ -395,7 +395,7 @@ export class WebViewModel implements ViewModel {
giveFocus(): boolean {
console.log("webview giveFocus");
if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpenAtom)) {
if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) {
console.log("search is open, not giving focus");
return true;
}
@ -549,9 +549,9 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
// Search
const searchProps = useSearch(model.webviewRef, model);
const searchVal = useAtomValue<string>(searchProps.searchAtom);
const setSearchIndex = useSetAtom(searchProps.indexAtom);
const setNumSearchResults = useSetAtom(searchProps.numResultsAtom);
const searchVal = useAtomValue<string>(searchProps.searchValue);
const setSearchIndex = useSetAtom(searchProps.resultsIndex);
const setNumSearchResults = useSetAtom(searchProps.resultsCount);
searchProps.onSearch = useCallback((search: string) => {
try {
if (search) {

View File

@ -143,6 +143,7 @@ declare global {
type HeaderElem =
| IconButtonDecl
| ToggleIconButtonDecl
| HeaderText
| HeaderInput
| HeaderDiv
@ -150,19 +151,27 @@ declare global {
| ConnectionButton
| MenuButton;
type IconButtonDecl = {
elemtype: "iconbutton";
type IconButtonCommon = {
icon: string | React.ReactNode;
iconColor?: string;
iconSpin?: boolean;
className?: string;
title?: string;
click?: (e: React.MouseEvent<any>) => void;
longClick?: (e: React.MouseEvent<any>) => void;
disabled?: boolean;
noAction?: boolean;
};
type IconButtonDecl = IconButtonCommon & {
elemtype: "iconbutton";
click?: (e: React.MouseEvent<any>) => void;
longClick?: (e: React.MouseEvent<any>) => void;
};
type ToggleIconButtonDecl = IconButtonCommon & {
elemtype: "toggleiconbutton";
active: jotai.WritableAtom<boolean, [boolean], void>;
};
type HeaderTextButton = {
elemtype: "textbutton";
text: string;
@ -229,10 +238,13 @@ declare global {
} & MenuButtonProps;
type SearchAtoms = {
searchAtom: PrimitiveAtom<string>;
indexAtom: PrimitiveAtom<number>;
numResultsAtom: PrimitiveAtom<number>;
isOpenAtom: PrimitiveAtom<boolean>;
searchValue: PrimitiveAtom<string>;
resultsIndex: PrimitiveAtom<number>;
resultsCount: PrimitiveAtom<number>;
isOpen: PrimitiveAtom<boolean>;
regex?: PrimitiveAtom<boolean>;
caseSensitive?: PrimitiveAtom<boolean>;
wholeWord?: PrimitiveAtom<boolean>;
};
interface ViewModel {