mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
567 lines
15 KiB
TypeScript
567 lines
15 KiB
TypeScript
import { debounce } from "es-toolkit/compat";
|
||
import {
|
||
CaretDownIcon,
|
||
CaretUpIcon,
|
||
CaseSensitiveIcon,
|
||
RegexIcon,
|
||
ReplaceIcon,
|
||
} from "outline-icons";
|
||
import * as React from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import styled, { useTheme } from "styled-components";
|
||
import { depths, s } from "@shared/styles";
|
||
import { altDisplay, isModKey, metaDisplay } from "@shared/utils/keyboard";
|
||
import Button from "~/components/Button";
|
||
import Flex from "~/components/Flex";
|
||
import Input from "~/components/Input";
|
||
import NudeButton from "~/components/NudeButton";
|
||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||
import Tooltip from "~/components/Tooltip";
|
||
import {
|
||
Popover,
|
||
PopoverTrigger,
|
||
PopoverContent,
|
||
} from "~/components/primitives/Popover";
|
||
import useKeyDown from "~/hooks/useKeyDown";
|
||
import Desktop from "~/utils/Desktop";
|
||
import { useEditor } from "./EditorContext";
|
||
import { HStack } from "~/components/primitives/HStack";
|
||
|
||
type KeyboardShortcutsProps = {
|
||
open: boolean;
|
||
handleOpen: ({ withReplace }: { withReplace: boolean }) => void;
|
||
handleCaseSensitive: () => void;
|
||
handleRegex: () => void;
|
||
};
|
||
|
||
function useKeyboardShortcuts({
|
||
open,
|
||
handleOpen,
|
||
handleCaseSensitive,
|
||
handleRegex,
|
||
}: KeyboardShortcutsProps) {
|
||
// Open popover
|
||
useKeyDown(
|
||
(ev) =>
|
||
isModKey(ev) &&
|
||
!open &&
|
||
ev.code === "KeyF" &&
|
||
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
|
||
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
|
||
(ev) => {
|
||
ev.preventDefault();
|
||
handleOpen({ withReplace: ev.altKey });
|
||
},
|
||
{ allowInInput: true }
|
||
);
|
||
|
||
// Enable/disable case sensitive search
|
||
useKeyDown(
|
||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && open,
|
||
(ev) => {
|
||
ev.preventDefault();
|
||
handleCaseSensitive();
|
||
},
|
||
{ allowInInput: true }
|
||
);
|
||
|
||
// Enable/disable regex search
|
||
useKeyDown(
|
||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && open,
|
||
(ev) => {
|
||
ev.preventDefault();
|
||
handleRegex();
|
||
},
|
||
{ allowInInput: true }
|
||
);
|
||
}
|
||
|
||
type Props = {
|
||
/** Whether the find and replace popover is open */
|
||
open: boolean;
|
||
/** Callback when the find and replace popover is opened */
|
||
onOpen: () => void;
|
||
/** Callback when the find and replace popover is closed */
|
||
onClose: () => void;
|
||
/** Whether the editor is in read-only mode */
|
||
readOnly?: boolean;
|
||
/** The current highlighted index in the search results */
|
||
currentIndex: number;
|
||
/** The total number of search results */
|
||
totalResults: number;
|
||
};
|
||
|
||
export default function FindAndReplace({
|
||
readOnly,
|
||
open,
|
||
onOpen,
|
||
onClose,
|
||
currentIndex,
|
||
totalResults,
|
||
}: Props) {
|
||
const editor = useEditor();
|
||
const [localOpen, setLocalOpen] = React.useState(open);
|
||
const selectionRef = React.useRef<string | undefined>();
|
||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||
const inputReplaceRef = React.useRef<HTMLInputElement>(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("");
|
||
|
||
React.useEffect(() => {
|
||
if (open) {
|
||
setLocalOpen(true);
|
||
}
|
||
}, [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();
|
||
setLocalOpen(true);
|
||
});
|
||
}
|
||
if ("onReplaceInPage" in Desktop.bridge) {
|
||
Desktop.bridge.onReplaceInPage(() => {
|
||
setShowReplace(true);
|
||
setLocalOpen(true);
|
||
});
|
||
}
|
||
}, []);
|
||
|
||
// Callbacks
|
||
const selectInputText = React.useCallback(() => {
|
||
inputRef.current?.focus();
|
||
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
|
||
}, []);
|
||
|
||
const selectInputReplaceText = React.useCallback(() => {
|
||
setTimeout(() => {
|
||
inputReplaceRef.current?.focus();
|
||
inputReplaceRef.current?.setSelectionRange(
|
||
0,
|
||
inputReplaceRef.current?.value.length
|
||
);
|
||
}, 100);
|
||
}, []);
|
||
|
||
const handleOpen = React.useCallback(
|
||
({ withReplace }: { withReplace: boolean }) => {
|
||
const shouldShowReplace = !readOnly && withReplace;
|
||
|
||
// If already open, switch focus to corresponding input text.
|
||
if (localOpen) {
|
||
if (shouldShowReplace) {
|
||
setShowReplace(true);
|
||
selectInputReplaceText();
|
||
} else {
|
||
selectInputText();
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
selectionRef.current = window.getSelection()?.toString();
|
||
setLocalOpen(true);
|
||
|
||
if (shouldShowReplace) {
|
||
setShowReplace(true);
|
||
}
|
||
},
|
||
[localOpen, readOnly, selectInputText, selectInputReplaceText]
|
||
);
|
||
|
||
const handleMore = React.useCallback(() => {
|
||
setShowReplace((state) => !state);
|
||
setTimeout(() => inputReplaceRef.current?.focus(), 100);
|
||
}, []);
|
||
|
||
const handleCaseSensitive = React.useCallback(() => {
|
||
setCaseSensitive((state) => {
|
||
const isCaseSensitive = !state;
|
||
|
||
editor.commands.find({
|
||
text: searchTerm,
|
||
caseSensitive: isCaseSensitive,
|
||
regexEnabled,
|
||
});
|
||
|
||
return isCaseSensitive;
|
||
});
|
||
}, [regexEnabled, editor.commands, searchTerm]);
|
||
|
||
const handleRegex = React.useCallback(() => {
|
||
setRegex((state) => {
|
||
const isRegexEnabled = !state;
|
||
|
||
editor.commands.find({
|
||
text: searchTerm,
|
||
caseSensitive,
|
||
regexEnabled: isRegexEnabled,
|
||
});
|
||
|
||
return isRegexEnabled;
|
||
});
|
||
}, [caseSensitive, editor.commands, searchTerm]);
|
||
|
||
// Searching the document on every keystroke is expensive in long documents –
|
||
// it traverses the entire doc and rebuilds highlights – so debounce.
|
||
const debouncedFind = React.useMemo(
|
||
() =>
|
||
debounce(
|
||
(attrs: {
|
||
text: string;
|
||
caseSensitive: boolean;
|
||
regexEnabled: boolean;
|
||
}) => {
|
||
editor.commands.find(attrs);
|
||
},
|
||
100
|
||
),
|
||
[editor.commands]
|
||
);
|
||
|
||
React.useEffect(() => () => debouncedFind.cancel(), [debouncedFind]);
|
||
|
||
const handleKeyDown = React.useCallback(
|
||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||
function nextPrevious() {
|
||
// Ensure any pending debounced search has run so navigation acts on the
|
||
// results for the text currently in the input.
|
||
debouncedFind.flush();
|
||
if (ev.shiftKey) {
|
||
editor.commands.prevSearchMatch();
|
||
} else {
|
||
editor.commands.nextSearchMatch();
|
||
}
|
||
}
|
||
|
||
switch (ev.key) {
|
||
case "Enter": {
|
||
ev.preventDefault();
|
||
nextPrevious();
|
||
return;
|
||
}
|
||
case "g": {
|
||
if (ev.metaKey) {
|
||
ev.preventDefault();
|
||
nextPrevious();
|
||
selectInputText();
|
||
}
|
||
return;
|
||
}
|
||
case "F3": {
|
||
ev.preventDefault();
|
||
nextPrevious();
|
||
selectInputText();
|
||
return;
|
||
}
|
||
}
|
||
},
|
||
[debouncedFind, editor.commands, selectInputText]
|
||
);
|
||
|
||
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<HTMLInputElement>) => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
setSearchTerm(ev.currentTarget.value);
|
||
|
||
debouncedFind({
|
||
text: ev.currentTarget.value,
|
||
caseSensitive,
|
||
regexEnabled,
|
||
});
|
||
},
|
||
[caseSensitive, debouncedFind, regexEnabled]
|
||
);
|
||
|
||
const handleReplaceKeyDown = React.useCallback(
|
||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (ev.key === "Enter") {
|
||
ev.preventDefault();
|
||
handleReplace(ev);
|
||
}
|
||
},
|
||
[handleReplace]
|
||
);
|
||
|
||
useKeyboardShortcuts({
|
||
open: localOpen,
|
||
handleOpen,
|
||
handleCaseSensitive,
|
||
handleRegex,
|
||
});
|
||
|
||
const style: React.CSSProperties = React.useMemo(
|
||
() => ({
|
||
position: "fixed",
|
||
top: 0,
|
||
right: 0,
|
||
zIndex: depths.popover,
|
||
}),
|
||
[]
|
||
);
|
||
|
||
React.useEffect(() => {
|
||
if (localOpen) {
|
||
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);
|
||
// Cancel any pending debounced find so it can't reactivate highlights
|
||
// after the search has been cleared.
|
||
debouncedFind.cancel();
|
||
editor.commands.clearSearch();
|
||
}
|
||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [localOpen]);
|
||
|
||
const disabled = totalResults === 0;
|
||
const navigation = (
|
||
<>
|
||
<Tooltip
|
||
content={t("Previous match")}
|
||
shortcut="Shift+Enter"
|
||
placement="bottom"
|
||
>
|
||
<ButtonLarge
|
||
disabled={disabled}
|
||
onClick={() => {
|
||
debouncedFind.flush();
|
||
editor.commands.prevSearchMatch();
|
||
}}
|
||
aria-label={t("Previous match")}
|
||
>
|
||
<CaretUpIcon />
|
||
</ButtonLarge>
|
||
</Tooltip>
|
||
<Tooltip content={t("Next match")} shortcut="Enter" placement="bottom">
|
||
<ButtonLarge
|
||
disabled={disabled}
|
||
onClick={() => {
|
||
debouncedFind.flush();
|
||
editor.commands.nextSearchMatch();
|
||
}}
|
||
aria-label={t("Next match")}
|
||
>
|
||
<CaretDownIcon />
|
||
</ButtonLarge>
|
||
</Tooltip>
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<Popover open={localOpen} onOpenChange={setLocalOpen}>
|
||
<PopoverTrigger>
|
||
<button
|
||
type="button"
|
||
aria-label={t("Find and replace")}
|
||
style={{ ...style, background: "none", border: 0, padding: 0 }}
|
||
/>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
aria-label={t("Find and replace")}
|
||
width={0}
|
||
minWidth={420}
|
||
scrollable={false}
|
||
onPointerDownOutside={() => setLocalOpen(false)}
|
||
onFocusOutside={(event) => {
|
||
event.preventDefault();
|
||
inputRef.current?.focus();
|
||
}}
|
||
style={{ marginRight: 16, marginTop: 60 }}
|
||
>
|
||
<Content column>
|
||
<Flex gap={4}>
|
||
<StyledInput
|
||
ref={inputRef}
|
||
maxLength={255}
|
||
value={searchTerm}
|
||
placeholder={`${t("Find")}…`}
|
||
onChange={handleChangeFind}
|
||
onKeyDown={handleKeyDown}
|
||
>
|
||
<SearchModifiers gap={8}>
|
||
<Tooltip
|
||
content={t("Match case")}
|
||
shortcut={`${altDisplay}+${metaDisplay}+c`}
|
||
placement="bottom"
|
||
>
|
||
<ButtonSmall
|
||
onClick={handleCaseSensitive}
|
||
aria-label={t("Match case")}
|
||
>
|
||
<CaseSensitiveIcon
|
||
color={caseSensitive ? theme.accent : theme.textSecondary}
|
||
/>
|
||
</ButtonSmall>
|
||
</Tooltip>
|
||
<Tooltip
|
||
content={t("Enable regex")}
|
||
shortcut={`${altDisplay}+${metaDisplay}+r`}
|
||
placement="bottom"
|
||
>
|
||
<ButtonSmall
|
||
onClick={handleRegex}
|
||
aria-label={t("Enable regex")}
|
||
>
|
||
<RegexIcon
|
||
color={regexEnabled ? theme.accent : theme.textSecondary}
|
||
/>
|
||
</ButtonSmall>
|
||
</Tooltip>
|
||
</SearchModifiers>
|
||
</StyledInput>
|
||
{navigation}
|
||
{!readOnly && (
|
||
<Tooltip
|
||
content={t("Replace options")}
|
||
shortcut={`${altDisplay}+${metaDisplay}+f`}
|
||
placement="bottom"
|
||
>
|
||
<ButtonLarge
|
||
onClick={handleMore}
|
||
aria-label={t("Replace options")}
|
||
>
|
||
<ReplaceIcon color={theme.textSecondary} />
|
||
</ButtonLarge>
|
||
</Tooltip>
|
||
)}
|
||
<Results>
|
||
{totalResults > 0 ? currentIndex + 1 : 0} / {totalResults}
|
||
</Results>
|
||
</Flex>
|
||
<ResizingHeightContainer>
|
||
{showReplace && !readOnly && (
|
||
<HStack align="flex-start">
|
||
<StyledInput
|
||
maxLength={255}
|
||
value={replaceTerm}
|
||
ref={inputReplaceRef}
|
||
placeholder={t("Replacement")}
|
||
onKeyDown={handleReplaceKeyDown}
|
||
onRequestSubmit={handleReplaceAll}
|
||
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
|
||
/>
|
||
<Tooltip
|
||
content={t("Replace")}
|
||
shortcut="Enter"
|
||
placement="bottom"
|
||
>
|
||
<Button onClick={handleReplace} disabled={disabled} neutral>
|
||
{t("Replace")}
|
||
</Button>
|
||
</Tooltip>
|
||
<Tooltip
|
||
content={t("Replace all")}
|
||
shortcut={`${metaDisplay}+Enter`}
|
||
placement="bottom"
|
||
>
|
||
<Button
|
||
onClick={handleReplaceAll}
|
||
disabled={disabled}
|
||
neutral
|
||
>
|
||
{t("Replace all")}
|
||
</Button>
|
||
</Tooltip>
|
||
</HStack>
|
||
)}
|
||
</ResizingHeightContainer>
|
||
</Content>
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
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")};
|
||
}
|
||
|
||
&:disabled {
|
||
color: ${s("textTertiary")};
|
||
background: none;
|
||
cursor: default;
|
||
}
|
||
`;
|
||
|
||
const ButtonLarge = styled(ButtonSmall)`
|
||
width: 32px;
|
||
height: 32px;
|
||
`;
|
||
|
||
const Content = styled(Flex)`
|
||
padding: 8px 0;
|
||
margin-bottom: -16px;
|
||
position: static;
|
||
`;
|
||
|
||
const Results = styled.span`
|
||
color: ${s("textSecondary")};
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
font-variant-numeric: tabular-nums;
|
||
line-height: 32px;
|
||
min-width: 32px;
|
||
letter-spacing: -0.5px;
|
||
text-align: right;
|
||
user-select: none;
|
||
`;
|