mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
b3d4563730
* Fix editor find freezing on long documents In-document search (Ctrl+F) blocked the UI for several seconds while typing in long documents. Two compounding causes: - The find command ran a full-document search and highlight rebuild on every keystroke. Debounce it so typing stays responsive; the input value still updates immediately and pending searches are flushed when navigating between matches. - search() de-duplicated matches with an O(n) scan of all prior results per match, making a common term that matches many times quadratic. Track seen positions in a Set for constant-time lookups. * Skip redundant search highlight rebuilds, lower debounce to 100ms The highlight plugin rebuilt every match's DOM range via domAtPos on every editor view update while a search was active, forcing synchronous layout on cursor moves, selection changes, and collaboration cursors. Track the built ranges and, when the result set is unchanged, only rebuild when they are actually stale — a referenced node has detached or some matches were not yet resolved to ranges. isConnected checks are cheap property reads with no layout, versus domAtPos which forces reflow, so this is strictly less work than before and skips entirely in the common case where all matches are resolved and connected. Also lower the find debounce from 250ms to 100ms for snappier feedback. * Shorten highlight rebuild comment * PR feedback --------- Co-authored-by: Claude <noreply@anthropic.com>
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);
|
||
},
|
||
250
|
||
),
|
||
[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;
|
||
`;
|