import { CaretDownIcon, CaretUpIcon, CaseSensitiveIcon, RegexIcon, ReplaceIcon, } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { usePopoverState } from "reakit/Popover"; import styled, { useTheme } from "styled-components"; import { depths, s } from "@shared/styles"; import Button from "~/components/Button"; import Flex from "~/components/Flex"; import Input from "~/components/Input"; import NudeButton from "~/components/NudeButton"; import Popover from "~/components/Popover"; import { Portal } from "~/components/Portal"; import { ResizingHeightContainer } from "~/components/ResizingHeightContainer"; import Tooltip from "~/components/Tooltip"; import useKeyDown from "~/hooks/useKeyDown"; import useOnClickOutside from "~/hooks/useOnClickOutside"; import Desktop from "~/utils/Desktop"; import { altDisplay, isModKey, metaDisplay } from "~/utils/keyboard"; import { useEditor } from "./EditorContext"; type Props = { open: boolean; onOpen: () => void; onClose: () => void; readOnly?: boolean; }; export default function FindAndReplace({ readOnly, open, onOpen, onClose, }: Props) { const editor = useEditor(); const finalFocusRef = React.useRef( editor.view.dom.parentElement ); const selectionRef = React.useRef(); const inputRef = React.useRef(null); const inputReplaceRef = React.useRef(null); const { t } = useTranslation(); const theme = useTheme(); const [showReplace, setShowReplace] = React.useState(false); const [caseSensitive, setCaseSensitive] = React.useState(false); const [regexEnabled, setRegex] = React.useState(false); const [searchTerm, setSearchTerm] = React.useState(""); const [replaceTerm, setReplaceTerm] = React.useState(""); const popover = usePopoverState(); const { show } = popover; React.useEffect(() => { if (open) { show(); } }, [open]); // Hooks for desktop app menu items React.useEffect(() => { if (!Desktop.bridge) { return; } if ("onFindInPage" in Desktop.bridge) { Desktop.bridge.onFindInPage(() => { selectionRef.current = window.getSelection()?.toString(); show(); }); } if ("onReplaceInPage" in Desktop.bridge) { Desktop.bridge.onReplaceInPage(() => { setShowReplace(true); show(); }); } }, [show]); useOnClickOutside(popover.unstable_referenceRef, popover.hide); // Keyboard shortcuts useKeyDown( (ev) => isModKey(ev) && !popover.visible && ev.code === "KeyF" && // Keyboard handler is through the AppMenu on Desktop v1.2.0+ !(Desktop.bridge && "onFindInPage" in Desktop.bridge), (ev) => { ev.preventDefault(); selectionRef.current = window.getSelection()?.toString(); popover.show(); } ); useKeyDown( (ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible, (ev) => { ev.preventDefault(); setRegex((state) => !state); }, { allowInInput: true } ); useKeyDown( (ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible, (ev) => { ev.preventDefault(); setCaseSensitive((state) => !state); }, { allowInInput: true } ); // Callbacks const handleMore = React.useCallback(() => { setShowReplace((state) => !state); setTimeout(() => inputReplaceRef.current?.focus(), 100); }, []); const handleCaseSensitive = React.useCallback(() => { setCaseSensitive((state) => { const caseSensitive = !state; editor.commands.find({ text: searchTerm, caseSensitive, regexEnabled, }); return caseSensitive; }); }, [regexEnabled, editor.commands, searchTerm]); const handleRegex = React.useCallback(() => { setRegex((state) => { const regexEnabled = !state; editor.commands.find({ text: searchTerm, caseSensitive, regexEnabled, }); return regexEnabled; }); }, [caseSensitive, editor.commands, searchTerm]); const handleKeyDown = React.useCallback( (ev: React.KeyboardEvent) => { function nextPrevious(ev: React.KeyboardEvent) { if (ev.shiftKey) { editor.commands.prevSearchMatch(); } else { editor.commands.nextSearchMatch(); } } function selectInputText() { inputRef.current?.setSelectionRange(0, inputRef.current?.value.length); } switch (ev.key) { case "Enter": { ev.preventDefault(); nextPrevious(ev); return; } case "g": { if (ev.metaKey) { ev.preventDefault(); nextPrevious(ev); selectInputText(); } return; } case "F3": { ev.preventDefault(); nextPrevious(ev); selectInputText(); return; } } }, [editor.commands] ); const handleReplace = React.useCallback( (ev) => { if (readOnly) { return; } ev.preventDefault(); editor.commands.replace({ text: replaceTerm }); }, [editor.commands, readOnly, replaceTerm] ); const handleReplaceAll = React.useCallback( (ev) => { if (readOnly) { return; } ev.preventDefault(); editor.commands.replaceAll({ text: replaceTerm }); }, [editor.commands, readOnly, replaceTerm] ); const handleChangeFind = React.useCallback( (ev: React.ChangeEvent) => { ev.preventDefault(); ev.stopPropagation(); setSearchTerm(ev.currentTarget.value); editor.commands.find({ text: ev.currentTarget.value, caseSensitive, regexEnabled, }); }, [caseSensitive, editor.commands, regexEnabled] ); const handleReplaceKeyDown = React.useCallback( (ev: React.KeyboardEvent) => { if (ev.key === "Enter") { ev.preventDefault(); handleReplace(ev); } }, [handleReplace] ); const style: React.CSSProperties = React.useMemo( () => ({ position: "fixed", left: "initial", top: 60, right: 16, zIndex: depths.popover, }), [] ); React.useEffect(() => { if (popover.visible) { onOpen(); const startSearchText = selectionRef.current || searchTerm; editor.commands.find({ text: startSearchText, caseSensitive, regexEnabled, }); requestAnimationFrame(() => { inputRef.current?.setSelectionRange(0, startSearchText.length); }); if (selectionRef.current) { setSearchTerm(selectionRef.current); } } else { onClose(); setShowReplace(false); editor.commands.clearSearch(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [popover.visible]); const navigation = ( <> editor.commands.prevSearchMatch()}> editor.commands.nextSearchMatch()}> ); return ( {navigation} {!readOnly && ( )} {showReplace && !readOnly && ( setReplaceTerm(ev.currentTarget.value)} /> )} ); } const SearchModifiers = styled(Flex)` margin-right: 4px; `; const StyledInput = styled(Input)` width: 196px; flex: 1; `; const ButtonSmall = styled(NudeButton)` &:hover, &[aria-expanded="true"] { background: ${s("sidebarControlHoverBackground")}; } `; const ButtonLarge = styled(ButtonSmall)` width: 32px; height: 32px; `; const Content = styled(Flex)` padding: 8px 0; margin-bottom: -16px; position: static; `;