feat: Add find and replace interface (#5642)

This commit is contained in:
Tom Moor
2023-08-03 18:47:44 -04:00
committed by GitHub
parent eda023c908
commit b691311f88
13 changed files with 683 additions and 20 deletions

View File

@@ -30,6 +30,8 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
color: ${s("text")};
height: 30px;
min-width: 0;
font-size: 15px;
${ellipsis()}
${undraggableOnDesktop()}

View File

@@ -200,6 +200,7 @@ const Link = styled(NavLink)<{
text-overflow: ellipsis;
padding: 6px 16px;
border-radius: 4px;
min-height: 32px;
transition: background 50ms, color 50ms;
user-select: none;
background: ${(props) =>

View File

@@ -0,0 +1,330 @@
import {
CaretDownIcon,
CaretUpIcon,
CaseSensitiveIcon,
MoreIcon,
RegexIcon,
} 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 { altDisplay, isModKey, metaDisplay } from "~/utils/keyboard";
import { useEditor } from "./EditorContext";
type Props = {
readOnly?: boolean;
};
export default function FindAndReplace({ readOnly }: Props) {
const editor = useEditor();
const selectionRef = React.useRef<string | undefined>();
const inputRef = 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("");
const popover = usePopoverState();
useKeyDown("Escape", popover.hide);
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
useKeyDown(
(ev) => isModKey(ev) && !popover.visible && ev.code === "KeyF",
(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 }
);
const handleMore = React.useCallback(
() => setShowReplace((state) => !state),
[]
);
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<HTMLInputElement>) => {
if (ev.key === "Enter") {
ev.preventDefault();
if (ev.shiftKey) {
editor.commands.prevSearchMatch();
} else {
editor.commands.nextSearchMatch();
}
}
},
[editor.commands]
);
const handleReplace = React.useCallback(
(ev) => {
ev.preventDefault();
editor.commands.replace({ text: replaceTerm });
},
[editor.commands, replaceTerm]
);
const handleReplaceAll = React.useCallback(
(ev) => {
ev.preventDefault();
editor.commands.replaceAll({ text: replaceTerm });
},
[editor.commands, replaceTerm]
);
const handleChangeFind = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
if (ev.key === "Enter") {
ev.preventDefault();
handleReplace(ev);
}
},
[handleReplace]
);
const style: React.CSSProperties = React.useMemo(
() => ({
position: "absolute",
left: "initial",
top: 60,
right: 16,
zIndex: depths.popover,
}),
[]
);
React.useEffect(() => {
if (popover.visible) {
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 {
setShowReplace(false);
editor.commands.clearSearch();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [popover.visible]);
const navigation = (
<>
<Tooltip
tooltip={t("Previous match")}
shortcut="shift+enter"
delay={500}
placement="bottom"
>
<ButtonLarge onClick={() => editor.commands.prevSearchMatch()}>
<CaretUpIcon />
</ButtonLarge>
</Tooltip>
<Tooltip
tooltip={t("Next match")}
shortcut="enter"
delay={500}
placement="bottom"
>
<ButtonLarge onClick={() => editor.commands.nextSearchMatch()}>
<CaretDownIcon />
</ButtonLarge>
</Tooltip>
</>
);
return (
<Portal>
<Popover
{...popover}
style={style}
aria-label={t("Find and replace")}
width={420}
>
<Content column>
<Flex gap={8}>
<StyledInput
ref={inputRef}
maxLength={255}
value={searchTerm}
placeholder={`${t("Find")}`}
onChange={handleChangeFind}
onKeyDown={handleKeyDown}
>
<SearchModifiers gap={8}>
<Tooltip
tooltip={t("Match case")}
shortcut={`${altDisplay}+${metaDisplay}+c`}
delay={500}
placement="bottom"
>
<ButtonSmall onClick={handleCaseSensitive}>
<CaseSensitiveIcon
color={caseSensitive ? theme.accent : theme.textSecondary}
/>
</ButtonSmall>
</Tooltip>
<Tooltip
tooltip={t("Enable regex")}
shortcut={`${altDisplay}+${metaDisplay}+r`}
delay={500}
placement="bottom"
>
<ButtonSmall onClick={handleRegex}>
<RegexIcon
color={regexEnabled ? theme.accent : theme.textSecondary}
/>
</ButtonSmall>
</Tooltip>
</SearchModifiers>
</StyledInput>
{navigation}
{!readOnly && (
<Tooltip
tooltip={t("More options")}
delay={500}
placement="bottom"
>
<ButtonLarge onClick={handleMore}>
<MoreIcon color={theme.textSecondary} />
</ButtonLarge>
</Tooltip>
)}
</Flex>
<ResizingHeightContainer>
{showReplace && (
<Flex gap={8}>
<StyledInput
maxLength={255}
value={replaceTerm}
placeholder={t("Replacement")}
onKeyDown={handleReplaceKeyDown}
onRequestSubmit={handleReplaceAll}
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
/>
<Button onClick={handleReplace} neutral>
{t("Replace")}
</Button>
<Button onClick={handleReplaceAll} neutral>
{t("Replace all")}
</Button>
</Flex>
)}
</ResizingHeightContainer>
</Content>
</Popover>
</Portal>
);
}
const SearchModifiers = styled(Flex)`
margin-right: 4px;
`;
const StyledInput = styled(Input)`
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;
`;

View File

@@ -46,6 +46,7 @@ import BlockMenu from "./components/BlockMenu";
import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext";
import EmojiMenu from "./components/EmojiMenu";
import FindAndReplace from "./components/FindAndReplace";
import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar";
import MentionMenu from "./components/MentionMenu";
@@ -770,17 +771,20 @@ export class Editor extends React.PureComponent<
ref={this.elementRef}
/>
{this.view && (
<SelectionToolbar
rtl={isRTL}
readOnly={readOnly}
canComment={this.props.canComment}
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionToolbar}
onClose={this.handleCloseSelectionToolbar}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
/>
<>
<SelectionToolbar
rtl={isRTL}
readOnly={readOnly}
canComment={this.props.canComment}
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionToolbar}
onClose={this.handleCloseSelectionToolbar}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
/>
{this.commands.find && <FindAndReplace readOnly={readOnly} />}
</>
)}
{!readOnly && this.view && (
<>

View File

@@ -8,7 +8,7 @@ import useEventListener from "./useEventListener";
* @param callback The handler to call when a click outside the element is detected.
*/
export default function useOnClickOutside(
ref: React.RefObject<HTMLElement>,
ref: React.RefObject<HTMLElement | null>,
callback?: (event: MouseEvent | TouchEvent) => void
) {
const listener = React.useCallback(

View File

@@ -141,7 +141,7 @@
"natural-sort": "^1.0.0",
"node-fetch": "2.6.12",
"nodemailer": "^6.9.1",
"outline-icons": "^2.2.0",
"outline-icons": "^2.3.0",
"oy-vey": "^0.12.0",
"passport": "^0.6.0",
"passport-google-oauth2": "^0.2.0",

View File

@@ -1,6 +1,6 @@
/* eslint-disable no-irregular-whitespace */
import { lighten, transparentize } from "polished";
import styled, { DefaultTheme, css } from "styled-components";
import styled, { DefaultTheme, css, keyframes } from "styled-components";
export type Props = {
rtl: boolean;
@@ -11,6 +11,12 @@ export type Props = {
theme: DefaultTheme;
};
export const pulse = keyframes`
0% { box-shadow: 0 0 0 1px rgba(255, 213, 0, 0.75) }
50% { box-shadow: 0 0 0 4px rgba(255, 213, 0, 0.75) }
100% { box-shadow: 0 0 0 1px rgba(255, 213, 0, 0.75) }
`;
const codeMarkCursor = () => css`
/* Based on https://github.com/curvenote/editor/blob/main/packages/prosemirror-codemark/src/codemark.css */
.no-cursor {
@@ -232,6 +238,17 @@ const codeBlockStyle = (props: Props) => css`
}
`;
const findAndReplaceStyle = () => css`
.find-result {
background: rgba(255, 213, 0, 0.25);
&.current-result {
background: rgba(255, 213, 0, 0.75);
animation: ${pulse} 150ms 1;
}
}
`;
const style = (props: Props) => `
flex-grow: ${props.grow ? 1 : 0};
justify-content: start;
@@ -1481,6 +1498,7 @@ const EditorContainer = styled.div<Props>`
${mathStyle}
${codeMarkCursor}
${codeBlockStyle}
${findAndReplaceStyle}
`;
export default EditorContainer;

View File

@@ -0,0 +1,291 @@
import { escapeRegExp } from "lodash";
import { Node } from "prosemirror-model";
import { Command, Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import Extension from "../lib/Extension";
const pluginKey = new PluginKey("find-and-replace");
export default class FindAndReplace extends Extension {
public get name() {
return "find-and-replace";
}
public get defaultOptions() {
return {
resultClassName: "find-result",
resultCurrentClassName: "current-result",
caseSensitive: false,
regexEnabled: false,
};
}
public commands() {
return {
/**
* Find all matching results in the document for the given options
*
* @param attrs.text The search query
* @param attrs.caseSensitive Whether the search should be case sensitive
* @param attrs.regexEnabled Whether the search should be a regex
*
* @returns A command that finds all matching results
*/
find: (attrs: {
text: string;
caseSensitive?: boolean;
regexEnabled?: boolean;
}) => this.find(attrs.text, attrs.caseSensitive, attrs.regexEnabled),
/**
* Find and highlight the next matching result in the document
*/
nextSearchMatch: () => this.goToMatch(1),
/**
* Find and highlight the previous matching result in the document
*/
prevSearchMatch: () => this.goToMatch(-1),
/**
* Replace the current highlighted result with the given text
*
* @param attrs.text The text to replace the current result with
*/
replace: (attrs: { text: string }) => this.replace(attrs.text),
/**
* Replace all matching results with the given text
*
* @param attrs.text The text to replace all results with
*/
replaceAll: (attrs: { text: string }) => this.replaceAll(attrs.text),
/**
* Clear the current search
*/
clearSearch: () => this.clear(),
};
}
private get decorations() {
return this.results.map((deco, index) =>
Decoration.inline(deco.from, deco.to, {
class:
this.options.resultClassName +
(this.currentResultIndex === index
? ` ${this.options.resultCurrentClassName}`
: ""),
})
);
}
public replace(replace: string): Command {
return (state, dispatch) => {
const result = this.results[this.currentResultIndex];
if (!result) {
return false;
}
const { from, to } = result;
dispatch?.(state.tr.insertText(replace, from, to).setMeta(pluginKey, {}));
return true;
};
}
public replaceAll(replace: string): Command {
return ({ tr }, dispatch) => {
let offset: number | undefined;
if (!this.results.length) {
return false;
}
this.results.forEach(({ from, to }, index) => {
tr.insertText(replace, from, to);
offset = this.rebaseNextResult(replace, index, offset);
});
dispatch?.(tr);
return true;
};
}
public find(
searchTerm: string,
caseSensitive = this.options.caseSensitive,
regexEnabled = this.options.regexEnabled
): Command {
return (state, dispatch) => {
this.options.caseSensitive = caseSensitive;
this.options.regexEnabled = regexEnabled;
this.searchTerm = regexEnabled ? searchTerm : escapeRegExp(searchTerm);
this.currentResultIndex = 0;
dispatch?.(state.tr.setMeta(pluginKey, {}));
return true;
};
}
public clear(): Command {
return (state, dispatch) => {
this.searchTerm = "";
this.currentResultIndex = 0;
dispatch?.(state.tr.setMeta(pluginKey, {}));
return true;
};
}
private get findRegExp() {
return RegExp(this.searchTerm, !this.options.caseSensitive ? "gui" : "gu");
}
private goToMatch(direction: number): Command {
return (state, dispatch) => {
if (direction > 0) {
if (this.currentResultIndex === this.results.length - 1) {
this.currentResultIndex = 0;
} else {
this.currentResultIndex += 1;
}
} else {
if (this.currentResultIndex === 0) {
this.currentResultIndex = this.results.length - 1;
} else {
this.currentResultIndex -= 1;
}
}
dispatch?.(state.tr.setMeta(pluginKey, {}));
const element = window.document.querySelector(
`.${this.options.resultCurrentClassName}`
);
if (element) {
void scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
}
return true;
};
}
private rebaseNextResult(replace: string, index: number, lastOffset = 0) {
const nextIndex = index + 1;
if (!this.results[nextIndex]) {
return undefined;
}
const { from: currentFrom, to: currentTo } = this.results[index];
const offset = currentTo - currentFrom - replace.length + lastOffset;
const { from, to } = this.results[nextIndex];
this.results[nextIndex] = {
to: to - offset,
from: from - offset,
};
return offset;
}
private search(doc: Node) {
this.results = [];
const mergedTextNodes: {
text: string | undefined;
pos: number;
}[] = [];
let index = 0;
if (!this.searchTerm) {
return;
}
doc.descendants((node, pos) => {
if (node.isText) {
if (mergedTextNodes[index]) {
mergedTextNodes[index] = {
text: mergedTextNodes[index].text + (node.text ?? ""),
pos: mergedTextNodes[index].pos,
};
} else {
mergedTextNodes[index] = {
text: node.text,
pos,
};
}
} else {
index += 1;
}
});
mergedTextNodes.forEach(({ text = "", pos }) => {
const search = this.findRegExp;
let m;
while ((m = search.exec(text))) {
if (m[0] === "") {
break;
}
this.results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
});
}
private createDeco(doc: Node) {
this.search(doc);
return this.decorations
? DecorationSet.create(doc, this.decorations)
: DecorationSet.empty;
}
get allowInReadOnly() {
return true;
}
get focusAfterExecution() {
return false;
}
get plugins() {
return [
new Plugin({
key: pluginKey,
state: {
init: () => DecorationSet.empty,
apply: (tr, decorationSet) => {
const action = tr.getMeta(pluginKey);
if (action) {
return this.createDeco(tr.doc);
}
if (tr.docChanged) {
return decorationSet.map(tr.mapping, tr.doc);
}
return decorationSet;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}),
];
}
private results: { from: number; to: number }[] = [];
private currentResultIndex = 0;
private searchTerm = "";
}

View File

@@ -46,6 +46,10 @@ export default class Extension {
return false;
}
get focusAfterExecution(): boolean {
return true;
}
keys(_options: {
type?: NodeType | MarkType;
schema: Schema;

View File

@@ -209,7 +209,9 @@ export default class ExtensionManager {
if (!view.editable && !extension.allowInReadOnly) {
return false;
}
view.focus();
if (extension.focusAfterExecution) {
view.focus();
}
return callback(attrs)(view.state, view.dispatch, view);
};

View File

@@ -1,6 +1,7 @@
import BlockMenu from "../extensions/BlockMenu";
import ClipboardTextSerializer from "../extensions/ClipboardTextSerializer";
import DateTime from "../extensions/DateTime";
import FindAndReplace from "../extensions/FindAndReplace";
import History from "../extensions/History";
import Keys from "../extensions/Keys";
import MaxLength from "../extensions/MaxLength";
@@ -109,6 +110,7 @@ export const richExtensions: Nodes = [
Math,
MathBlock,
PreventTab,
FindAndReplace,
];
/**

View File

@@ -247,6 +247,16 @@
"Save": "Save",
"New name": "New name",
"Name can't be empty": "Name can't be empty",
"Previous match": "Previous match",
"Next match": "Next match",
"Find and replace": "Find and replace",
"Find": "Find",
"Match case": "Match case",
"Enable regex": "Enable regex",
"More options": "More options",
"Replacement": "Replacement",
"Replace": "Replace",
"Replace all": "Replace all",
"Profile picture": "Profile picture",
"Insert column after": "Insert column after",
"Insert column before": "Insert column before",
@@ -542,7 +552,6 @@
"All users see the same publicly shared view": "All users see the same publicly shared view",
"Custom link": "Custom link",
"The document will be accessible at <2>{{url}}</2>": "The document will be accessible at <2>{{url}}</2>",
"More options": "More options",
"Close": "Close",
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",

View File

@@ -10068,10 +10068,10 @@ os-tmpdir@~1.0.2:
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==
outline-icons@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-2.2.0.tgz#0ca59aa92da9364c1f1ed01e24858e9c034c6661"
integrity sha512-9QjFdxoCGGFz2RwsXYz2XLrHhS/qwH5tTq/tGG8hObaH4uD/0UDfK/80WY6aTBRoyGqZm3/gwRNl+lR2rELE2g==
outline-icons@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-2.3.0.tgz#f1a5910b77c1167ffa466951f4a3bcca182c3a8d"
integrity sha512-DpTLh1YuflJ4+aO0U9DutbMJX86uIsG0rk0ONRxTtIbDIXZrkMXQ9pynssnI5FT9ZdQeNBx7AQjHOSRmxzT3HQ==
oy-vey@^0.12.0:
version "0.12.0"