web: add character, spaces, paragraphs count in status bar popup (#8311)

* web: add character, spaces, paragraphs count in status bar popup
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: refactor selected text stats logic
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: use PopupPresenter for note statistics popup
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: improve gettng text for counting characters
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
This commit is contained in:
01zulfi
2025-07-07 11:54:01 +05:00
committed by GitHub
parent 29c9476c16
commit 3ca0089ee8
8 changed files with 241 additions and 34 deletions

View File

@@ -28,23 +28,21 @@ import {
Plus, Plus,
Minus, Minus,
EditorNormalWidth, EditorNormalWidth,
TableOfContents,
ExitFullscreen, ExitFullscreen,
Fullscreen, Fullscreen,
EditorFullWidth, EditorFullWidth,
NormalMode NormalMode,
Cross
} from "../icons"; } from "../icons";
import { import { useEditorConfig, useNoteStatistics } from "./manager";
useEditorConfig,
useNoteStatistics,
useEditorManager
} from "./manager";
import { getFormattedDate } from "@notesnook/common"; import { getFormattedDate } from "@notesnook/common";
import { MAX_AUTO_SAVEABLE_WORDS } from "./types"; import { MAX_AUTO_SAVEABLE_WORDS, NoteStatistics } from "./types";
import { strings } from "@notesnook/intl"; import { strings } from "@notesnook/intl";
import { EDITOR_ZOOM } from "./common"; import { EDITOR_ZOOM } from "./common";
import { useWindowControls } from "../../hooks/use-window-controls"; import { useWindowControls } from "../../hooks/use-window-controls";
import { exitFullscreen } from "../../utils/fullscreen"; import { exitFullscreen } from "../../utils/fullscreen";
import { useRef, useState } from "react";
import { PopupPresenter } from "@notesnook/ui";
const SAVE_STATE_ICON_MAP = { const SAVE_STATE_ICON_MAP = {
"-1": NotSaved, "-1": NotSaved,
@@ -54,11 +52,13 @@ const SAVE_STATE_ICON_MAP = {
function EditorFooter() { function EditorFooter() {
const { isFullscreen } = useWindowControls(); const { isFullscreen } = useWindowControls();
const { words } = useNoteStatistics(); const statistics = useNoteStatistics();
const session = useEditorStore((store) => store.getActiveSession()); const session = useEditorStore((store) => store.getActiveSession());
const { editorConfig, setEditorConfig } = useEditorConfig(); const { editorConfig, setEditorConfig } = useEditorConfig();
const editorMargins = useEditorStore((store) => store.editorMargins); const editorMargins = useEditorStore((store) => store.editorMargins);
const isFocusMode = useAppStore((store) => store.isFocusMode); const isFocusMode = useAppStore((store) => store.isFocusMode);
const [isStatisticsPopupOpen, setIsStatisticsPopupOpen] = useState(false);
const statisticsRef = useRef<HTMLButtonElement | null>(null);
if (!session) return null; if (!session) return null;
@@ -169,7 +169,7 @@ function EditorFooter() {
<Plus size={13} /> <Plus size={13} />
</Button> </Button>
</Flex> </Flex>
{words.total > MAX_AUTO_SAVEABLE_WORDS ? ( {statistics.words.total > MAX_AUTO_SAVEABLE_WORDS ? (
<Text <Text
className="selectable" className="selectable"
variant="subBody" variant="subBody"
@@ -178,15 +178,76 @@ function EditorFooter() {
{strings.autoSaveOff()} {strings.autoSaveOff()}
</Text> </Text>
) : null} ) : null}
<Text <Button
className="selectable" className="selectable"
data-test-id="editor-word-count" data-test-id="editor-word-count"
variant="subBody" variant="statusitem"
sx={{ color: "paragraph" }} ref={statisticsRef}
onClick={() => setIsStatisticsPopupOpen(true)}
> >
{strings.totalWords(words.total)} <Text variant="subBody" sx={{ color: "paragraph" }}>
{words.selected ? ` (${strings.selectedWords(words.selected)})` : ""} {strings.totalWords(statistics.words.total)}
</Text> {statistics.words.selected
? ` (${strings.selectedWords(statistics.words.selected)})`
: ""}
</Text>
</Button>
<PopupPresenter
isOpen={isStatisticsPopupOpen}
onClose={() => setIsStatisticsPopupOpen(false)}
position={{
isTargetAbsolute: true,
target: statisticsRef.current,
location: "top",
align: "center",
yOffset: 5
}}
>
<Flex
sx={{
width: ["100%", "fit-content"],
border: "1px solid",
borderColor: "border",
borderRadius: "dialog",
boxShadow: "0px 0px 15px 0px #00000011",
bg: "background-secondary",
flexDirection: "column",
gap: 2,
pt: 2,
justifyContent: "center"
}}
>
{Object.entries(statistics).map(([key, value]) => (
<Flex key={key} sx={{ justifyContent: "space-between", px: 4 }}>
<Text variant="body" sx={{ width: 100 }}>
{strings[key as keyof NoteStatistics]()}
</Text>
<Text
variant="body"
sx={{ color: "paragraph", fontWeight: "bold" }}
>
{value.total}
{value.selected ? (
<span style={{ fontWeight: "normal" }}>
{" "}
({value.selected} selected)
</span>
) : (
""
)}
</Text>
</Flex>
))}
<Button
data-test-id="dialog-no"
onClick={() => setIsStatisticsPopupOpen(false)}
color="paragraph"
variant="icon"
>
<Cross size={12} />
</Button>
</Flex>
</PopupPresenter>
{dateEdited > 0 ? ( {dateEdited > 0 ? (
<Text <Text
className="selectable" className="selectable"

View File

@@ -119,7 +119,10 @@ export function useNoteStatistics(): NoteStatistics {
(store) => (store) =>
(store.activeEditorId && (store.activeEditorId &&
store.editors[store.activeEditorId]?.statistics) || { store.editors[store.activeEditorId]?.statistics) || {
words: { total: 0 } words: { total: 0 },
characters: { total: 0 },
paragraphs: { total: 0 },
spaces: { total: 0 }
} }
); );
} }

View File

@@ -37,7 +37,8 @@ import {
Attachment, Attachment,
getTableOfContents, getTableOfContents,
getChangedNodes, getChangedNodes,
LinkAttributes LinkAttributes,
type Selection
} from "@notesnook/editor"; } from "@notesnook/editor";
import { Box, Flex } from "@theme-ui/components"; import { Box, Flex } from "@theme-ui/components";
import { import {
@@ -107,19 +108,51 @@ type TipTapProps = {
fontLigatures: boolean; fontLigatures: boolean;
}; };
function updateWordCount(id: string, content: () => Fragment) { function countCharacters(text: string) {
return text.length;
}
function countParagraphs(fragment: Fragment) {
let count = 0;
fragment.nodesBetween(0, fragment.size, (node) => {
if (node.type.name === "paragraph") {
count++;
}
return true;
});
return count;
}
function countSpaces(text: string) {
return (text.match(/ /g) || []).length;
}
function updateNoteStatistics(id: string, content: () => Fragment) {
const fragment = content(); const fragment = content();
const documentText = fragment.textBetween(0, fragment.size, "\n", " ");
useEditorManager.getState().updateEditor(id, { useEditorManager.getState().updateEditor(id, {
statistics: { statistics: {
words: { words: {
total: countWords(fragment.textBetween(0, fragment.size, "\n", " ")), total: countWords(documentText),
selected: 0
},
characters: {
total: countCharacters(removeNewlineCharacters(documentText)),
selected: 0
},
paragraphs: {
total: countParagraphs(fragment),
selected: 0
},
spaces: {
total: countSpaces(documentText),
selected: 0 selected: 0
} }
} }
}); });
} }
const deferredUpdateWordCount = debounce(updateWordCount, 1000); const deferredUpdateNoteStatistics = debounce(updateNoteStatistics, 1000);
function TipTap(props: TipTapProps) { function TipTap(props: TipTapProps) {
const { const {
@@ -222,6 +255,18 @@ function TipTap(props: TipTapProps) {
words: { words: {
total: totalWords, total: totalWords,
selected: 0 selected: 0
},
characters: {
total: countCharacters(editor.state.doc.textContent),
selected: 0
},
paragraphs: {
total: countParagraphs(editor.state.doc.content),
selected: 0
},
spaces: {
total: countSpaces(editor.state.doc.textContent),
selected: 0
} }
}, },
tableOfContents: getTableOfContents(editor.view.dom) tableOfContents: getTableOfContents(editor.view.dom)
@@ -240,7 +285,7 @@ function TipTap(props: TipTapProps) {
onContentChange?.(); onContentChange?.();
deferredUpdateWordCount(id, () => editor.state.doc.content); deferredUpdateNoteStatistics(id, () => editor.state.doc.content);
const preventSave = transaction?.getMeta("preventSave") as boolean; const preventSave = transaction?.getMeta("preventSave") as boolean;
const ignoreEdit = transaction.getMeta("ignoreEdit") as boolean; const ignoreEdit = transaction.getMeta("ignoreEdit") as boolean;
@@ -274,22 +319,63 @@ function TipTap(props: TipTapProps) {
useEditorManager.getState().updateEditor(id, (old) => { useEditorManager.getState().updateEditor(id, (old) => {
const oldSelected = old.statistics?.words?.selected; const oldSelected = old.statistics?.words?.selected;
const oldWords = old.statistics?.words.total || 0; const oldWords = old.statistics?.words.total || 0;
if (isEmptySelection) const oldParagraphs = old.statistics?.paragraphs.total || 0;
const oldSpaces = old.statistics?.spaces.total || 0;
const oldCharacters = old.statistics?.characters.total || 0;
if (isEmptySelection) {
return oldSelected return oldSelected
? { ? {
statistics: { words: { total: oldWords, selected: 0 } } statistics: {
words: { total: oldWords, selected: 0 },
characters: {
total: oldCharacters,
selected: 0
},
paragraphs: {
total: oldParagraphs,
selected: 0
},
spaces: {
total: oldSpaces,
selected: 0
}
}
} }
: old; : old;
}
const selectedWords = getSelectedWords( const selectedText = editor.state.doc.textBetween(
transaction.selection.from,
transaction.selection.to,
"\n",
" "
);
const selectedWords = countWords(selectedText);
const selectedSpaces = countSpaces(selectedText);
const selectedParagraphs = getSelectedParagraphs(
editor as Editor, editor as Editor,
transaction.selection transaction.selection
); );
const selectedCharacters = countCharacters(
removeNewlineCharacters(selectedText)
);
return { return {
statistics: { statistics: {
words: { words: {
total: oldWords, total: oldWords,
selected: selectedWords selected: selectedWords
},
characters: {
total: oldCharacters,
selected: selectedCharacters
},
paragraphs: {
total: oldParagraphs,
selected: selectedParagraphs
},
spaces: {
total: oldSpaces,
selected: selectedSpaces
} }
} }
}; };
@@ -620,12 +706,17 @@ function toIEditor(editor: Editor): IEditor {
}; };
} }
function getSelectedWords( function getSelectedParagraphs(editor: Editor, selection: Selection): number {
editor: Editor, let count = 0;
selection: { from: number; to: number; empty: boolean } editor.state.doc.nodesBetween(selection.from, selection.to, (node) => {
): number { if (node.type.name === "paragraph") {
const selectedText = selection.empty count++;
? "" }
: editor.state.doc.textBetween(selection.from, selection.to, "\n", " "); return true;
return countWords(selectedText); });
return count;
}
function removeNewlineCharacters(text: string) {
return text.replaceAll(/\n/g, "");
} }

View File

@@ -26,6 +26,18 @@ export type NoteStatistics = {
total: number; total: number;
selected?: number; selected?: number;
}; };
characters: {
total: number;
selected?: number;
};
paragraphs: {
total: number;
selected?: number;
};
spaces: {
total: number;
selected?: number;
};
}; };
export interface IEditor { export interface IEditor {

View File

@@ -438,3 +438,4 @@ export {
}; };
export { replaceDateTime } from "./extensions/date-time/index.js"; export { replaceDateTime } from "./extensions/date-time/index.js";
export type * from "./extension-imports.js"; export type * from "./extension-imports.js";
export { type Selection } from "@tiptap/pm/state";

View File

@@ -6,6 +6,12 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n" "X-Generator: @lingui/cli\n"
"Language: en\n" "Language: en\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/strings.ts:2410 #: src/strings.ts:2410
msgid " \"Notebook > Notes\"" msgid " \"Notebook > Notes\""
@@ -1340,6 +1346,10 @@ msgstr "Changes from other devices won't be updated in the editor in real-time."
msgid "Changing password is an irreversible process. You will be logged out from all your devices. Please make sure you do not close the app while your password is changing and have good internet connection." msgid "Changing password is an irreversible process. You will be logged out from all your devices. Please make sure you do not close the app while your password is changing and have good internet connection."
msgstr "Changing password is an irreversible process. You will be logged out from all your devices. Please make sure you do not close the app while your password is changing and have good internet connection." msgstr "Changing password is an irreversible process. You will be logged out from all your devices. Please make sure you do not close the app while your password is changing and have good internet connection."
#: src/strings.ts:2490
msgid "Characters"
msgstr "Characters"
#: src/strings.ts:1295 #: src/strings.ts:1295
msgid "Check for new version of Notesnook" msgid "Check for new version of Notesnook"
msgstr "Check for new version of Notesnook" msgstr "Check for new version of Notesnook"
@@ -4256,6 +4266,10 @@ msgstr "Outline list"
msgid "Paragraph" msgid "Paragraph"
msgstr "Paragraph" msgstr "Paragraph"
#: src/strings.ts:2491
msgid "Paragraphs"
msgstr "Paragraphs"
#: src/strings.ts:2100 #: src/strings.ts:2100
msgid "Partial backups contain all your data except attachments. They are created from data already available on your device and do not require an Internet connection." msgid "Partial backups contain all your data except attachments. They are created from data already available on your device and do not require an Internet connection."
msgstr "Partial backups contain all your data except attachments. They are created from data already available on your device and do not require an Internet connection." msgstr "Partial backups contain all your data except attachments. They are created from data already available on your device and do not require an Internet connection."
@@ -6837,6 +6851,10 @@ msgstr "What went wrong?"
msgid "Width" msgid "Width"
msgstr "Width" msgstr "Width"
#: src/strings.ts:2489
msgid "Words"
msgstr "Words"
#: src/strings.ts:2412 #: src/strings.ts:2412
msgid "Work & Office" msgid "Work & Office"
msgstr "Work & Office" msgstr "Work & Office"

View File

@@ -6,6 +6,12 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n" "X-Generator: @lingui/cli\n"
"Language: pseudo-LOCALE\n" "Language: pseudo-LOCALE\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/strings.ts:2410 #: src/strings.ts:2410
msgid " \"Notebook > Notes\"" msgid " \"Notebook > Notes\""
@@ -1340,6 +1346,10 @@ msgstr ""
msgid "Changing password is an irreversible process. You will be logged out from all your devices. Please make sure you do not close the app while your password is changing and have good internet connection." msgid "Changing password is an irreversible process. You will be logged out from all your devices. Please make sure you do not close the app while your password is changing and have good internet connection."
msgstr "" msgstr ""
#: src/strings.ts:2490
msgid "Characters"
msgstr ""
#: src/strings.ts:1295 #: src/strings.ts:1295
msgid "Check for new version of Notesnook" msgid "Check for new version of Notesnook"
msgstr "" msgstr ""
@@ -4230,6 +4240,10 @@ msgstr ""
msgid "Paragraph" msgid "Paragraph"
msgstr "" msgstr ""
#: src/strings.ts:2491
msgid "Paragraphs"
msgstr ""
#: src/strings.ts:2100 #: src/strings.ts:2100
msgid "Partial backups contain all your data except attachments. They are created from data already available on your device and do not require an Internet connection." msgid "Partial backups contain all your data except attachments. They are created from data already available on your device and do not require an Internet connection."
msgstr "" msgstr ""
@@ -6788,6 +6802,10 @@ msgstr ""
msgid "Width" msgid "Width"
msgstr "" msgstr ""
#: src/strings.ts:2489
msgid "Words"
msgstr ""
#: src/strings.ts:2412 #: src/strings.ts:2412
msgid "Work & Office" msgid "Work & Office"
msgstr "" msgstr ""

View File

@@ -2485,5 +2485,8 @@ Use this if changes from other devices are not appearing on this device. This wi
yourArchiveIsEmpty: () => t`Your archive is empty`, yourArchiveIsEmpty: () => t`Your archive is empty`,
unarchive: () => t`Unarchive`, unarchive: () => t`Unarchive`,
moveNotebookDesc: () => moveNotebookDesc: () =>
t`Select a notebook to move this notebook into, or unselect to move it to the root level.` t`Select a notebook to move this notebook into, or unselect to move it to the root level.`,
words: () => t`Words`,
characters: () => t`Characters`,
paragraphs: () => t`Paragraphs`
}; };