From b691311f88769b4e7808c4c184a1cb3fdcf0eb1c Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 3 Aug 2023 18:47:44 -0400 Subject: [PATCH] feat: Add find and replace interface (#5642) --- app/components/Input.tsx | 2 + .../Sidebar/components/SidebarLink.tsx | 1 + app/editor/components/FindAndReplace.tsx | 330 ++++++++++++++++++ app/editor/index.tsx | 26 +- app/hooks/useOnClickOutside.ts | 2 +- package.json | 2 +- shared/editor/components/Styles.ts | 20 +- shared/editor/extensions/FindAndReplace.ts | 291 +++++++++++++++ shared/editor/lib/Extension.ts | 4 + shared/editor/lib/ExtensionManager.ts | 4 +- shared/editor/nodes/index.ts | 2 + shared/i18n/locales/en_US/translation.json | 11 +- yarn.lock | 8 +- 13 files changed, 683 insertions(+), 20 deletions(-) create mode 100644 app/editor/components/FindAndReplace.tsx create mode 100644 shared/editor/extensions/FindAndReplace.ts diff --git a/app/components/Input.tsx b/app/components/Input.tsx index fd13ff7ae..6483a0ca9 100644 --- a/app/components/Input.tsx +++ b/app/components/Input.tsx @@ -30,6 +30,8 @@ const RealInput = styled.input<{ hasIcon?: boolean }>` color: ${s("text")}; height: 30px; min-width: 0; + font-size: 15px; + ${ellipsis()} ${undraggableOnDesktop()} diff --git a/app/components/Sidebar/components/SidebarLink.tsx b/app/components/Sidebar/components/SidebarLink.tsx index 1a3fdba58..8d160607a 100644 --- a/app/components/Sidebar/components/SidebarLink.tsx +++ b/app/components/Sidebar/components/SidebarLink.tsx @@ -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) => diff --git a/app/editor/components/FindAndReplace.tsx b/app/editor/components/FindAndReplace.tsx new file mode 100644 index 000000000..a5232271b --- /dev/null +++ b/app/editor/components/FindAndReplace.tsx @@ -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(); + const inputRef = 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(); + + 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) => { + 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) => { + 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: "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 = ( + <> + + editor.commands.prevSearchMatch()}> + + + + + editor.commands.nextSearchMatch()}> + + + + + ); + + return ( + + + + + + + + + + + + + + + + + + + {navigation} + {!readOnly && ( + + + + + + )} + + + {showReplace && ( + + setReplaceTerm(ev.currentTarget.value)} + /> + + + + )} + + + + + ); +} + +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; +`; diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 2eef67752..f060aee2c 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -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 && ( - + <> + + {this.commands.find && } + )} {!readOnly && this.view && ( <> diff --git a/app/hooks/useOnClickOutside.ts b/app/hooks/useOnClickOutside.ts index 0e41d7260..52043c93b 100644 --- a/app/hooks/useOnClickOutside.ts +++ b/app/hooks/useOnClickOutside.ts @@ -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, + ref: React.RefObject, callback?: (event: MouseEvent | TouchEvent) => void ) { const listener = React.useCallback( diff --git a/package.json b/package.json index 2b05346ea..ea1be0905 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts index 4ccdb58b6..be0648d17 100644 --- a/shared/editor/components/Styles.ts +++ b/shared/editor/components/Styles.ts @@ -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` ${mathStyle} ${codeMarkCursor} ${codeBlockStyle} + ${findAndReplaceStyle} `; export default EditorContainer; diff --git a/shared/editor/extensions/FindAndReplace.ts b/shared/editor/extensions/FindAndReplace.ts new file mode 100644 index 000000000..60c97d86e --- /dev/null +++ b/shared/editor/extensions/FindAndReplace.ts @@ -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 = ""; +} diff --git a/shared/editor/lib/Extension.ts b/shared/editor/lib/Extension.ts index dcf6ac136..03f7ee01d 100644 --- a/shared/editor/lib/Extension.ts +++ b/shared/editor/lib/Extension.ts @@ -46,6 +46,10 @@ export default class Extension { return false; } + get focusAfterExecution(): boolean { + return true; + } + keys(_options: { type?: NodeType | MarkType; schema: Schema; diff --git a/shared/editor/lib/ExtensionManager.ts b/shared/editor/lib/ExtensionManager.ts index 86ac9e252..27d083e22 100644 --- a/shared/editor/lib/ExtensionManager.ts +++ b/shared/editor/lib/ExtensionManager.ts @@ -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); }; diff --git a/shared/editor/nodes/index.ts b/shared/editor/nodes/index.ts index 201facefa..48c2a90cb 100644 --- a/shared/editor/nodes/index.ts +++ b/shared/editor/nodes/index.ts @@ -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, ]; /** diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index fe91d8980..78253923f 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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}}": "The document will be accessible at <2>{{url}}", - "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 {{ documentTitle }} template?": "Are you sure you want to delete the {{ documentTitle }} template?", diff --git a/yarn.lock b/yarn.lock index dd7c14e52..f63bf94bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"