mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-29 10:09:26 +02:00
Compare commits
4 Commits
fixbbb
...
feature/im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eecfabef61 | ||
|
|
19a5d6353d | ||
|
|
0ad1006076 | ||
|
|
9b4c711285 |
@@ -363,46 +363,59 @@ function EditorChrome(
|
||||
) : null}
|
||||
|
||||
<Toolbar />
|
||||
<FlexScrollContainer
|
||||
className="editorScroll"
|
||||
style={{ display: "flex", flexDirection: "column", flex: 1 }}
|
||||
>
|
||||
<Flex
|
||||
variant="columnFill"
|
||||
className="editor"
|
||||
sx={{
|
||||
alignSelf: ["stretch", focusMode ? "center" : "stretch", "center"],
|
||||
maxWidth: editorMargins ? "min(100%, 850px)" : "auto",
|
||||
width: "100%"
|
||||
}}
|
||||
px={6}
|
||||
onClick={onRequestFocus}
|
||||
<Flex sx={{ justifyContent: "center", overflow: "hidden", flex: 1 }}>
|
||||
<FlexScrollContainer
|
||||
className="editorScroll"
|
||||
style={{ display: "flex", flexDirection: "column", flex: 1 }}
|
||||
>
|
||||
{!isMobile && (
|
||||
<Box
|
||||
id="editorToolbar"
|
||||
sx={{
|
||||
display: readonly ? "none" : "flex",
|
||||
bg: "background",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
mb: 1,
|
||||
zIndex: 2
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Titlebox readonly={readonly || false} />
|
||||
<Header readonly={readonly} />
|
||||
<AnimatedFlex
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isLoading ? 0 : 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
<Flex
|
||||
variant="columnFill"
|
||||
className="editor"
|
||||
sx={{
|
||||
alignSelf: [
|
||||
"stretch",
|
||||
focusMode ? "center" : "stretch",
|
||||
"center"
|
||||
],
|
||||
maxWidth: editorMargins ? "min(100%, 850px)" : "auto",
|
||||
width: "100%"
|
||||
}}
|
||||
px={6}
|
||||
onClick={onRequestFocus}
|
||||
>
|
||||
{children}
|
||||
</AnimatedFlex>
|
||||
</Flex>
|
||||
</FlexScrollContainer>
|
||||
|
||||
{!isMobile && (
|
||||
<Box
|
||||
id="editorToolbar"
|
||||
sx={{
|
||||
display: readonly ? "none" : "flex",
|
||||
bg: "background",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
mb: 1,
|
||||
zIndex: 2
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Titlebox readonly={readonly || false} />
|
||||
<Header readonly={readonly} />
|
||||
<AnimatedFlex
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isLoading ? 0 : 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
{children}
|
||||
</AnimatedFlex>
|
||||
</Flex>
|
||||
</FlexScrollContainer>
|
||||
<Flex
|
||||
id="editorSidebar"
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
borderLeft: "1px solid var(--border)"
|
||||
}}
|
||||
></Flex>
|
||||
</Flex>
|
||||
{isMobile && (
|
||||
<Box
|
||||
id="editorToolbar"
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
TextSelection,
|
||||
Transaction
|
||||
} from "prosemirror-state";
|
||||
import { Node } from "prosemirror-model";
|
||||
|
||||
type DispatchFn = (tr: Transaction) => void;
|
||||
declare module "@tiptap/core" {
|
||||
@@ -35,6 +36,7 @@ declare module "@tiptap/core" {
|
||||
endSearch: () => ReturnType;
|
||||
refreshSearch: () => ReturnType;
|
||||
search: (term: string, options?: SearchSettings) => ReturnType;
|
||||
moveToResult: (index: number) => ReturnType;
|
||||
moveToNextResult: () => ReturnType;
|
||||
moveToPreviousResult: () => ReturnType;
|
||||
replace: (term: string) => ReturnType;
|
||||
@@ -43,9 +45,13 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
}
|
||||
|
||||
interface Result {
|
||||
export interface SearchResult {
|
||||
from: number;
|
||||
to: number;
|
||||
preview: {
|
||||
text: string;
|
||||
match: { from: number; to: number };
|
||||
};
|
||||
}
|
||||
|
||||
interface SearchOptions {
|
||||
@@ -64,10 +70,10 @@ export type SearchStorage = SearchSettings & {
|
||||
isSearching: boolean;
|
||||
focusNonce: number;
|
||||
selectedText?: string;
|
||||
results?: Result[];
|
||||
results?: SearchResult[];
|
||||
};
|
||||
|
||||
interface TextNodesWithPosition {
|
||||
interface TextNodeWithPosition {
|
||||
text: string;
|
||||
pos: number;
|
||||
}
|
||||
@@ -82,7 +88,6 @@ const updateView = (state: EditorState, dispatch: DispatchFn) => {
|
||||
const regex = (s: string, settings: SearchSettings): RegExp => {
|
||||
const { enableRegex, matchCase, matchWholeWord } = settings;
|
||||
const boundary = matchWholeWord ? "\\b" : "";
|
||||
console.log(boundary);
|
||||
return RegExp(
|
||||
boundary +
|
||||
(enableRegex ? s : s.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")) +
|
||||
@@ -96,7 +101,11 @@ function searchDocument(
|
||||
searchResultClass: string,
|
||||
searchTerm?: RegExp,
|
||||
selectedIndex?: number
|
||||
): { decorationSet: DecorationSet; results: Result[]; startIndex: number } {
|
||||
): {
|
||||
decorationSet: DecorationSet;
|
||||
results: SearchResult[];
|
||||
startIndex: number;
|
||||
} {
|
||||
if (!searchTerm)
|
||||
return {
|
||||
decorationSet: DecorationSet.empty,
|
||||
@@ -106,45 +115,9 @@ function searchDocument(
|
||||
|
||||
const doc = tr.doc;
|
||||
const decorations: Decoration[] = [];
|
||||
const results: Result[] = [];
|
||||
|
||||
let index = 0;
|
||||
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
||||
|
||||
doc?.descendants((node, pos) => {
|
||||
if (node.isText) {
|
||||
if (textNodesWithPosition[index]) {
|
||||
textNodesWithPosition[index] = {
|
||||
text: textNodesWithPosition[index].text + node.text,
|
||||
pos: textNodesWithPosition[index].pos
|
||||
};
|
||||
} else {
|
||||
textNodesWithPosition[index] = {
|
||||
text: node.text || "",
|
||||
pos
|
||||
};
|
||||
}
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
});
|
||||
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
|
||||
|
||||
for (const { text, pos } of textNodesWithPosition) {
|
||||
const matches = text.matchAll(searchTerm);
|
||||
|
||||
for (const m of matches) {
|
||||
if (m[0] === "") break;
|
||||
|
||||
if (m.index !== undefined) {
|
||||
results.push({
|
||||
from: pos + m.index,
|
||||
to: pos + m.index + m[0].length
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const results = searchInNode(searchTerm, doc);
|
||||
|
||||
// find the match we want to highlight in all the search results
|
||||
const { from: selectedFrom, to: selectedTo } = tr.selection;
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const { from, to } = results[i];
|
||||
@@ -159,10 +132,13 @@ function searchDocument(
|
||||
}
|
||||
}
|
||||
|
||||
// add decorations around all the matches
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const { from, to } = results[i];
|
||||
const resultClass =
|
||||
i === selectedIndex ? `${searchResultClass} selected` : searchResultClass;
|
||||
const isSelected = i === selectedIndex;
|
||||
const resultClass = isSelected
|
||||
? `${searchResultClass} selected`
|
||||
: searchResultClass;
|
||||
decorations.push(Decoration.inline(from, to, { class: resultClass }));
|
||||
}
|
||||
|
||||
@@ -173,9 +149,59 @@ function searchDocument(
|
||||
};
|
||||
}
|
||||
|
||||
function searchInNode(term: RegExp, node: Node) {
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
// search in all the extracted text nodes
|
||||
for (const { text, pos } of extractTextWithPosition(node)) {
|
||||
const matches = text.matchAll(term);
|
||||
|
||||
for (const m of matches) {
|
||||
if (m[0] === "") break;
|
||||
|
||||
if (m.index !== undefined) {
|
||||
results.push({
|
||||
from: pos + m.index,
|
||||
to: pos + m.index + m[0].length,
|
||||
preview: {
|
||||
text,
|
||||
match: {
|
||||
from: m.index,
|
||||
to: m.index + m[0].length
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function extractTextWithPosition(node: Node): TextNodeWithPosition[] {
|
||||
let currentNode: TextNodeWithPosition | null = null;
|
||||
const textNodesWithPosition: TextNodeWithPosition[] = [];
|
||||
node.descendants((node, pos) => {
|
||||
if (node.isText) {
|
||||
if (currentNode) {
|
||||
currentNode.text += node.text;
|
||||
} else {
|
||||
currentNode = {
|
||||
text: node.text || "",
|
||||
pos
|
||||
};
|
||||
textNodesWithPosition.push(currentNode);
|
||||
}
|
||||
} else {
|
||||
currentNode = null;
|
||||
}
|
||||
});
|
||||
return textNodesWithPosition;
|
||||
}
|
||||
|
||||
const replaceAll = (
|
||||
replaceTerm: string,
|
||||
results: Result[],
|
||||
results: SearchResult[],
|
||||
tr: Transaction
|
||||
) => {
|
||||
if (!results.length) return;
|
||||
@@ -187,10 +213,11 @@ const replaceAll = (
|
||||
tr.insertText(replaceTerm, from, to);
|
||||
|
||||
if (i + 1 < results.length) {
|
||||
const { from, to } = results[i + 1];
|
||||
const { from, to, preview } = results[i + 1];
|
||||
results[i + 1] = {
|
||||
from: map.map(from),
|
||||
to: map.map(to)
|
||||
to: map.map(to),
|
||||
preview
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -246,43 +273,44 @@ export const SearchReplace = Extension.create<SearchOptions, SearchStorage>({
|
||||
this.storage.matchWholeWord = options?.matchWholeWord || false;
|
||||
this.storage.results = [];
|
||||
|
||||
if (dispatch) updateView(state, dispatch);
|
||||
return true;
|
||||
},
|
||||
moveToResult:
|
||||
(index: number) =>
|
||||
({ state, dispatch, commands }) => {
|
||||
const { results } = this.storage;
|
||||
if (!results || results.length <= 0) return false;
|
||||
|
||||
const { from, to } = results[index];
|
||||
commands.setTextSelection({ from, to });
|
||||
scrollIntoView();
|
||||
|
||||
this.storage.selectedIndex = index;
|
||||
if (dispatch) updateView(state, dispatch);
|
||||
return true;
|
||||
},
|
||||
moveToNextResult:
|
||||
() =>
|
||||
({ state, dispatch, commands }) => {
|
||||
({ commands }) => {
|
||||
const { selectedIndex, results } = this.storage;
|
||||
if (!results || results.length <= 0) return false;
|
||||
|
||||
let nextIndex = selectedIndex + 1;
|
||||
if (isNaN(nextIndex) || nextIndex >= results.length) nextIndex = 0;
|
||||
|
||||
const { from, to } = results[nextIndex];
|
||||
commands.setTextSelection({ from, to });
|
||||
scrollIntoView();
|
||||
|
||||
this.storage.selectedIndex = nextIndex;
|
||||
if (dispatch) updateView(state, dispatch);
|
||||
return true;
|
||||
return commands.moveToResult(nextIndex);
|
||||
},
|
||||
moveToPreviousResult:
|
||||
() =>
|
||||
({ state, dispatch, commands }) => {
|
||||
({ commands }) => {
|
||||
const { selectedIndex, results } = this.storage;
|
||||
if (!results || results.length <= 0) return false;
|
||||
|
||||
let prevIndex = selectedIndex - 1;
|
||||
if (isNaN(prevIndex) || prevIndex < 0) prevIndex = results.length - 1;
|
||||
|
||||
const { from, to } = results[prevIndex];
|
||||
commands.setTextSelection({ from, to });
|
||||
scrollIntoView();
|
||||
|
||||
this.storage.selectedIndex = prevIndex;
|
||||
if (dispatch) updateView(state, dispatch);
|
||||
|
||||
return true;
|
||||
return commands.moveToResult(prevIndex);
|
||||
},
|
||||
replace:
|
||||
(term) =>
|
||||
@@ -297,10 +325,11 @@ export const SearchReplace = Extension.create<SearchOptions, SearchStorage>({
|
||||
tr.insertText(term, from, to);
|
||||
|
||||
if (index + 1 < results.length) {
|
||||
const { from, to } = results[index + 1];
|
||||
const { from, to, preview } = results[index + 1];
|
||||
const nextResult = (results[index + 1] = {
|
||||
from: tr.mapping.map(from),
|
||||
to: tr.mapping.map(to)
|
||||
to: tr.mapping.map(to),
|
||||
preview
|
||||
});
|
||||
|
||||
commands.focus();
|
||||
|
||||
@@ -20,31 +20,47 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import { SearchStorage } from "../../extensions/search-replace";
|
||||
import { FloatingMenuProps } from "./types";
|
||||
import { SearchReplacePopup } from "../popups/search-replace";
|
||||
import { ResponsivePresenter } from "../../components/responsive";
|
||||
import { SearchReplacePopupMobile } from "../popups/search-replace.mobile";
|
||||
import {
|
||||
DesktopOnly,
|
||||
MobileOnly,
|
||||
ResponsivePresenter
|
||||
} from "../../components/responsive";
|
||||
import { getToolbarElement } from "../utils/dom";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
export function SearchReplaceFloatingMenu(props: FloatingMenuProps) {
|
||||
const { editor } = props;
|
||||
const { isSearching } = editor.storage.searchreplace as SearchStorage;
|
||||
|
||||
return (
|
||||
<ResponsivePresenter
|
||||
mobile="sheet"
|
||||
desktop="menu"
|
||||
isOpen={isSearching}
|
||||
onClose={() => editor.commands.endSearch()}
|
||||
position={{
|
||||
target: getToolbarElement(),
|
||||
isTargetAbsolute: true,
|
||||
location: "below",
|
||||
align: "end",
|
||||
yOffset: 5
|
||||
}}
|
||||
blocking={false}
|
||||
focusOnRender={false}
|
||||
draggable={false}
|
||||
>
|
||||
<SearchReplacePopup editor={editor} />
|
||||
</ResponsivePresenter>
|
||||
<>
|
||||
<DesktopOnly>
|
||||
{isSearching &&
|
||||
ReactDOM.createPortal(
|
||||
<SearchReplacePopup editor={editor} />,
|
||||
document.getElementById("editorSidebar") || document.body
|
||||
)}
|
||||
</DesktopOnly>
|
||||
<MobileOnly>
|
||||
<ResponsivePresenter
|
||||
mobile="sheet"
|
||||
isOpen={isSearching}
|
||||
onClose={() => editor.commands.endSearch()}
|
||||
position={{
|
||||
target: getToolbarElement(),
|
||||
isTargetAbsolute: true,
|
||||
location: "below",
|
||||
align: "end",
|
||||
yOffset: 5
|
||||
}}
|
||||
blocking={false}
|
||||
focusOnRender={false}
|
||||
draggable={false}
|
||||
>
|
||||
<SearchReplacePopupMobile editor={editor} />
|
||||
</ResponsivePresenter>
|
||||
</MobileOnly>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
260
packages/editor/src/toolbar/popups/search-replace.mobile.tsx
Normal file
260
packages/editor/src/toolbar/popups/search-replace.mobile.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Input } from "@theme-ui/components";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Flex, Text } from "@theme-ui/components";
|
||||
import { SearchStorage } from "../../extensions/search-replace";
|
||||
import { ToolButton } from "../components/tool-button";
|
||||
import { Editor } from "../../types";
|
||||
|
||||
export type SearchReplacePopupProps = { editor: Editor };
|
||||
export function SearchReplacePopupMobile(props: SearchReplacePopupProps) {
|
||||
const { editor } = props;
|
||||
const { selectedText, results, selectedIndex } = editor.storage
|
||||
.searchreplace as SearchStorage;
|
||||
|
||||
const [isReplacing, setIsReplacing] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [matchCase, setMatchCase] = useState(false);
|
||||
const [matchWholeWord, setMatchWholeWord] = useState(false);
|
||||
const [enableRegex, setEnableRegex] = useState(false);
|
||||
const replaceText = useRef("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const search = useCallback(
|
||||
(term: string) => {
|
||||
editor.current?.commands.search(term, {
|
||||
matchCase,
|
||||
enableRegex,
|
||||
matchWholeWord
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[matchCase, enableRegex, matchWholeWord]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchInputRef.current) return;
|
||||
search(searchInputRef.current.value);
|
||||
}, [search, matchCase, matchWholeWord, enableRegex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedText) {
|
||||
if (searchInputRef.current) {
|
||||
const input = searchInputRef.current;
|
||||
setTimeout(() => {
|
||||
input.value = selectedText;
|
||||
input.focus();
|
||||
}, 0);
|
||||
}
|
||||
search(selectedText);
|
||||
}
|
||||
}, [selectedText, search]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
p: 1,
|
||||
bg: "background",
|
||||
flexDirection: "column",
|
||||
boxShadow: ["none", "menu"],
|
||||
borderRadius: [0, "default"]
|
||||
}}
|
||||
>
|
||||
<Flex>
|
||||
<Flex
|
||||
sx={{ flexDirection: "column", flex: 1, width: ["auto", 300], mr: 1 }}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
alignItems: "center",
|
||||
outline: "1px solid var(--border)",
|
||||
borderRadius: "default",
|
||||
p: 1,
|
||||
py: 0,
|
||||
":focus-within": {
|
||||
outlineColor: "primary",
|
||||
outlineWidth: "1.8px"
|
||||
},
|
||||
":hover": {
|
||||
outlineColor: "primary"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
variant={"clean"}
|
||||
defaultValue={selectedText}
|
||||
ref={searchInputRef}
|
||||
autoFocus
|
||||
placeholder="Find"
|
||||
sx={{ p: 0 }}
|
||||
onChange={(e) => {
|
||||
search(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
editor.commands.moveToNextResult();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Flex
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
mr: 0,
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<ToolButton
|
||||
sx={{
|
||||
mr: 0
|
||||
}}
|
||||
toggled={isExpanded}
|
||||
title="Expand"
|
||||
id="expand"
|
||||
icon={isExpanded ? "chevronRight" : "chevronLeft"}
|
||||
onClick={() => setIsExpanded((s) => !s)}
|
||||
iconSize={"medium"}
|
||||
/>
|
||||
{isExpanded && (
|
||||
<>
|
||||
<ToolButton
|
||||
sx={{
|
||||
mr: 0
|
||||
}}
|
||||
toggled={matchCase}
|
||||
title="Match case"
|
||||
id="matchCase"
|
||||
icon="caseSensitive"
|
||||
onClick={() => setMatchCase((s) => !s)}
|
||||
iconSize={"medium"}
|
||||
/>
|
||||
<ToolButton
|
||||
sx={{
|
||||
mr: 0
|
||||
}}
|
||||
toggled={matchWholeWord}
|
||||
title="Match whole word"
|
||||
id="matchWholeWord"
|
||||
icon="wholeWord"
|
||||
onClick={() => setMatchWholeWord((s) => !s)}
|
||||
iconSize={"medium"}
|
||||
/>
|
||||
<ToolButton
|
||||
sx={{
|
||||
mr: 0
|
||||
}}
|
||||
toggled={enableRegex}
|
||||
title="Enable regex"
|
||||
id="enableRegex"
|
||||
icon="regex"
|
||||
onClick={() => setEnableRegex((s) => !s)}
|
||||
iconSize={"medium"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Text
|
||||
variant={"subBody"}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
borderLeft: "1px solid var(--border)",
|
||||
color: "fontTertiary",
|
||||
px: 1
|
||||
}}
|
||||
>
|
||||
{results ? `${selectedIndex + 1}/${results.length}` : ""}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{isReplacing && (
|
||||
<Input
|
||||
sx={{ mt: 1, p: "7px" }}
|
||||
placeholder="Replace"
|
||||
onChange={(e) => (replaceText.current = e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex sx={{ flexDirection: "column" }}>
|
||||
<Flex sx={{ alignItems: "center", height: "33.2px" }}>
|
||||
<ToolButton
|
||||
toggled={isReplacing}
|
||||
title="Toggle replace"
|
||||
id="toggleReplace"
|
||||
icon="replace"
|
||||
onClick={() => setIsReplacing((s) => !s)}
|
||||
sx={{ mr: 0 }}
|
||||
iconSize={"big"}
|
||||
/>
|
||||
<ToolButton
|
||||
toggled={false}
|
||||
title="Previous match"
|
||||
id="previousMatch"
|
||||
icon="previousMatch"
|
||||
onClick={() => editor.commands.moveToPreviousResult()}
|
||||
sx={{ mr: 0 }}
|
||||
iconSize={"big"}
|
||||
/>
|
||||
<ToolButton
|
||||
toggled={false}
|
||||
title="Next match"
|
||||
id="nextMatch"
|
||||
icon="nextMatch"
|
||||
onClick={() => editor.commands.moveToNextResult()}
|
||||
sx={{ mr: 0 }}
|
||||
iconSize={"big"}
|
||||
/>
|
||||
<ToolButton
|
||||
toggled={false}
|
||||
title="Close"
|
||||
id="close"
|
||||
icon="close"
|
||||
onClick={() => editor.chain().focus().endSearch().run()}
|
||||
sx={{ mr: 0 }}
|
||||
iconSize={"big"}
|
||||
/>
|
||||
</Flex>
|
||||
{isReplacing && (
|
||||
<Flex sx={{ alignItems: "center", height: "33.2px", mt: 1 }}>
|
||||
<ToolButton
|
||||
toggled={false}
|
||||
title="Replace"
|
||||
id="replace"
|
||||
icon="replaceOne"
|
||||
onClick={() => editor.commands.replace(replaceText.current)}
|
||||
sx={{ mr: 0 }}
|
||||
iconSize={18}
|
||||
/>
|
||||
<ToolButton
|
||||
toggled={false}
|
||||
title="Replace all"
|
||||
id="replaceAll"
|
||||
icon="replaceAll"
|
||||
onClick={() => editor.commands.replaceAll(replaceText.current)}
|
||||
sx={{ mr: 0 }}
|
||||
iconSize={18}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -17,10 +17,10 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Input } from "@theme-ui/components";
|
||||
import { Button, Input } from "@theme-ui/components";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Flex, Text } from "@theme-ui/components";
|
||||
import { SearchStorage } from "../../extensions/search-replace";
|
||||
import { SearchResult, SearchStorage } from "../../extensions/search-replace";
|
||||
import { ToolButton } from "../components/tool-button";
|
||||
import { Editor } from "../../types";
|
||||
|
||||
@@ -80,16 +80,21 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
p: 1,
|
||||
bg: "background",
|
||||
flexDirection: "column",
|
||||
boxShadow: ["none", "menu"],
|
||||
borderRadius: [0, "default"]
|
||||
width: 300,
|
||||
flex: 1,
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<Flex>
|
||||
<Flex sx={{ p: 1 }}>
|
||||
<Flex
|
||||
sx={{ flexDirection: "column", flex: 1, width: ["auto", 300], mr: 1 }}
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
width: 300,
|
||||
mr: 1
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
@@ -180,17 +185,6 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Text
|
||||
variant={"subBody"}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
borderLeft: "1px solid var(--border)",
|
||||
color: "fontTertiary",
|
||||
px: 1
|
||||
}}
|
||||
>
|
||||
{results ? `${selectedIndex + 1}/${results.length}` : ""}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{isReplacing && (
|
||||
@@ -212,33 +206,6 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
|
||||
sx={{ mr: 0 }}
|
||||
iconSize={"big"}
|
||||
/>
|
||||
<ToolButton
|
||||
toggled={false}
|
||||
title="Previous match"
|
||||
id="previousMatch"
|
||||
icon="previousMatch"
|
||||
onClick={() => editor.current?.commands.moveToPreviousResult()}
|
||||
sx={{ mr: 0 }}
|
||||
iconSize={"big"}
|
||||
/>
|
||||
<ToolButton
|
||||
toggled={false}
|
||||
title="Next match"
|
||||
id="nextMatch"
|
||||
icon="nextMatch"
|
||||
onClick={() => editor.current?.commands.moveToNextResult()}
|
||||
sx={{ mr: 0 }}
|
||||
iconSize={"big"}
|
||||
/>
|
||||
<ToolButton
|
||||
toggled={false}
|
||||
title="Close"
|
||||
id="close"
|
||||
icon="close"
|
||||
onClick={() => editor.current?.chain().focus().endSearch().run()}
|
||||
sx={{ mr: 0 }}
|
||||
iconSize={"big"}
|
||||
/>
|
||||
</Flex>
|
||||
{isReplacing && (
|
||||
<Flex sx={{ alignItems: "center", height: "33.2px", mt: 1 }}>
|
||||
@@ -268,6 +235,155 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
mt: 1,
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden"
|
||||
}}
|
||||
>
|
||||
{results?.map((result, index) => (
|
||||
<SearchResultPreview
|
||||
key={result.from}
|
||||
index={index}
|
||||
searchResult={result}
|
||||
selectedIndex={selectedIndex}
|
||||
onClick={() => editor.current?.commands.moveToResult(index)}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
{!!results?.length && (
|
||||
<Flex
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
p: 1,
|
||||
// mt: 1,
|
||||
borderTop: "1px solid var(--border)"
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
variant={"subBody"}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
color: "fontTertiary"
|
||||
}}
|
||||
>
|
||||
{`${selectedIndex + 1} of ${results.length} results`}
|
||||
</Text>
|
||||
<Flex>
|
||||
<ToolButton
|
||||
toggled={false}
|
||||
title="Previous match"
|
||||
id="previousMatch"
|
||||
icon="previousMatch"
|
||||
onClick={() => editor.commands.moveToPreviousResult()}
|
||||
sx={{ mr: 0 }}
|
||||
iconSize={"big"}
|
||||
/>
|
||||
<ToolButton
|
||||
toggled={false}
|
||||
title="Next match"
|
||||
id="nextMatch"
|
||||
icon="nextMatch"
|
||||
onClick={() => editor.commands.moveToNextResult()}
|
||||
sx={{ mr: 0 }}
|
||||
iconSize={"big"}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultPreview({
|
||||
searchResult,
|
||||
index,
|
||||
selectedIndex,
|
||||
onClick
|
||||
}: {
|
||||
searchResult: SearchResult;
|
||||
index: number;
|
||||
selectedIndex: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { end, match, start } = splitSearchResult(searchResult);
|
||||
return (
|
||||
<Button
|
||||
variant="menuitem"
|
||||
title={searchResult.preview.text}
|
||||
sx={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
px: 1,
|
||||
py: 1,
|
||||
m: 0,
|
||||
alignSelf: "start",
|
||||
flexShrink: 0,
|
||||
textAlign: "left",
|
||||
width: "100%",
|
||||
...(selectedIndex === index
|
||||
? {
|
||||
bg: "hover",
|
||||
color: "primary"
|
||||
}
|
||||
: {})
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Text variant="subBody" sx={{ pr: 1 }}>
|
||||
{index + 1}.
|
||||
</Text>
|
||||
<Text variant="body">{start}</Text>
|
||||
<Text variant="body" sx={{ bg: "shade", color: "primary" }}>
|
||||
{match}
|
||||
</Text>
|
||||
<Text variant="body">{end}</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function splitSearchResult(searchResult: SearchResult, maxLength = 50) {
|
||||
const {
|
||||
text,
|
||||
match: { from, to }
|
||||
} = searchResult.preview;
|
||||
const remainingLength = maxLength - (to - from);
|
||||
const partLength = remainingLength / 2;
|
||||
|
||||
const match = text.substring(from, to);
|
||||
const start = truncate(text.substring(0, from), partLength, "start");
|
||||
const end = truncate(
|
||||
text.substring(to),
|
||||
partLength + (start.length < partLength ? partLength - start.length : 0),
|
||||
"end"
|
||||
);
|
||||
|
||||
return {
|
||||
start,
|
||||
match,
|
||||
end
|
||||
};
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLength: number, type: "end" | "start") {
|
||||
if (text.length <= maxLength) return text;
|
||||
if (type === "end") {
|
||||
return `${text.substring(0, maxLength)}...`;
|
||||
} else {
|
||||
return `...${text.slice(-maxLength)}`;
|
||||
}
|
||||
}
|
||||
// <ToolButton
|
||||
// toggled={false}
|
||||
// title="Close"
|
||||
// id="close"
|
||||
// icon="close"
|
||||
// onClick={() => editor.chain().focus().endSearch().run()}
|
||||
// sx={{ mr: 0 }}
|
||||
// iconSize={"big"}
|
||||
// />
|
||||
|
||||
Reference in New Issue
Block a user