Compare commits

...

4 Commits

Author SHA1 Message Date
Abdullah Atta
eecfabef61 web: make sure editor takes full vertical space 2023-03-20 09:46:18 +05:00
Abdullah Atta
19a5d6353d editor: branch out search ui for desktop 2023-03-18 13:01:36 +05:00
Abdullah Atta
0ad1006076 editor: refactor search 2023-03-18 12:59:38 +05:00
Abdullah Atta
9b4c711285 web: add editor sidebar container 2023-03-18 12:56:50 +05:00
5 changed files with 603 additions and 169 deletions

View File

@@ -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"

View File

@@ -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();

View File

@@ -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>
</>
);
}

View 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>
);
}

View File

@@ -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"}
// />