feat: Add find and replace interface (#5642)
This commit is contained in:
@@ -30,6 +30,8 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
|
||||
color: ${s("text")};
|
||||
height: 30px;
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
|
||||
${ellipsis()}
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
330
app/editor/components/FindAndReplace.tsx
Normal file
330
app/editor/components/FindAndReplace.tsx
Normal 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;
|
||||
`;
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
291
shared/editor/extensions/FindAndReplace.ts
Normal file
291
shared/editor/extensions/FindAndReplace.ts
Normal 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 = "";
|
||||
}
|
||||
@@ -46,6 +46,10 @@ export default class Extension {
|
||||
return false;
|
||||
}
|
||||
|
||||
get focusAfterExecution(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
keys(_options: {
|
||||
type?: NodeType | MarkType;
|
||||
schema: Schema;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user